Skip to content

Frontend Architecture

Pablo Martínez López edited this page Oct 20, 2026 · 11 revisions

Frontend Architecture

English · Español

The frontend is a React 19 + TypeScript single-page UI served inside the Tauri 2 WebView (WebKitGTK on Linux). It only paints and holds screen state: parsing, Scryfall, layout geometry, image processing, PDF and statistics all live in Rust (see Architecture). This page describes how the UI is assembled, the shared components, the src/lib helpers, the tooling configuration and the UI policies (text selection, scrolling, native tooltips, WebKitGTK workarounds).

Boundaries and non-negotiable rules

Rule Where it is enforced / implemented
The UI talks to Rust only through call() / on(), never a bare invoke. src/lib/ipc.ts (Commands, Events, call, on).
One geometry for screen and paper: positions come from Rust's PagePlan, the UI never recomputes them. src/stores/plan-store.ts (usePlan), see Layout Engine.
The web view never goes to the internet; all network is done by Rust. src-tauri/tauri.conf.json CSP (connect-src: ipc: http://ipc.localhost); see External Sources.
All user-visible text goes through t("<Spanish source>"). src/components/language-provider.tsx, scripts/idiomas.mjs; see Internationalization.
Scrolling uses the shared ScrollArea, never overflow-auto. src/components/ui/scroll-area.tsx.
No native title= tooltips; use <Tooltip> / <WithTooltip>. scripts/sin-title-nativo.mjs (pnpm check:title) and the runtime net src/components/tooltips-nativos.tsx.
Text is not selectable except inside inputs and "copy boxes" (select-text). src/index.css (@layer base).
Shared types come from Rust (#[derive(TS)]), generated into src/bindings/ and never hand-edited. pnpm bindings; see IPC and Error Handling.

Source map

Path Responsibility
src/main.tsx Bootstrap: provider tree, pre-mount theme, window show.
src/App.tsx Mounts AppShell (three views) plus every global dialog; starts the boot hooks.
src/components/ Shared, non-feature components (providers, Confirmar, campos.tsx, LimiteError, TooltipsNativos).
src/components/ui/ shadcn-style primitives on top of radix-ui (see table below).
src/lib/ Pure helpers and the IPC layer (ipc.ts, image-url.ts, deck-ops.ts, cartas.ts, trabajos.ts, dialogos.ts, imagen-viva.ts, errores.ts, themes.ts, tema-inicial.ts, temas-instalados.ts, SmoothScroll.ts, utils.ts).
src/stores/ Zustand stores, see State Stores.
src/features/<area>/ Screens per area: decklist, preview, export, variants, card-editor, library, stats, set-browser, shell, settings, disenar.
src/i18n/en/ English dictionaries, one file per area.
src/bindings/ Generated TS types from Rust (do not edit).
src/index.css, src/themes.css, src/fonts.css, src/styles/ Tailwind 4 entry, theme palettes, local fonts, skins and materials, see Themes and Skins.
src/hooks/use-foco-en-marcos.ts Returns focus from an <iframe>/<embed> viewer to the dialog so Esc keeps working.

Boot sequence

src/main.tsx does, in order:

  1. In production only (!import.meta.env.DEV), a bubbling contextmenu listener calls preventDefault() unless the event is already defaultPrevented (so Radix context menus on cards still work, but the WebView's "Reload / Inspect" menu never shows).
  2. prepararTemasInstalados() (src/lib/temas-instalados.ts): injects the CSS of installed tweakcn themes and appends them to THEMES before React mounts, because ThemeProvider only accepts a stored theme that is in THEMES.
  3. ponerTemaAntesDeMontar() (src/lib/tema-inicial.ts): writes data-theme, the dark class and the skin/background/material attributes on <html> so the very first paint already has the right colours (no white flash).
  4. Renders the provider tree (below).
  5. ensenarVentana() calls getCurrentWindow().show(). The window is created hidden with a dark background in tauri.conf.json (visible: false, backgroundColor), because WebKitGTK gives no animation frames (requestAnimationFrame) to a hidden window and waiting for a paint made the window appear ~3 s late. Rust also shows it on PageLoadEvent::Finished and a 3-second fallback thread in src-tauri/src/lib.rs; the window-state plugin is configured to not persist visibility.
flowchart TD
    A["React.StrictMode"] --> B["LimiteError (error boundary)"]
    B --> C["LanguageProvider"]
    C --> D["ThemeProvider"]
    D --> E["SmoothScrollClient"]
    D --> F["TooltipProvider"]
    F --> G["App"]
    F --> H["Toaster (sonner)"]
    F --> I["TooltipsNativos"]
    G --> J["AppShell: header + view"]
    G --> K["Global dialogs: AddCard, Variant, Vincular, CardEditor, Stats, SetBrowser, ..."]
Loading

Why this order:

  • LimiteError wraps everything so a render error shows a recovery screen instead of a blank window. Because it sits outside LanguageProvider, it cannot use useLang(); it uses traduccionSuelta (same dictionary, reads proxybuilder.lang directly).
  • LanguageProvider is outside ThemeProvider because tema-inicial/theme code needs no language, but any dialog needs both.
  • TooltipProvider encloses App so every Tooltip shares one delay (RETARDO_TOOLTIPS, 700 ms, defined only in src/components/ui/tooltip.tsx; skipDelayDuration is forced to 0 so Radix never opens a second tooltip instantly).

App.tsx: views and global dialogs

App() calls three hooks and renders AppShell plus the always-mounted dialogs:

  • useDeckInicial() (src/features/shell/use-deck-inicial.ts): starts iniciarAutoguardado() and iniciarPlan(), opens the last deck (or creates one), re-renders stale design thumbnails when a deck opens (rehacerMiniaturas), and installs the global Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z undo/redo (ignored inside inputs, textareas and contenteditable).
  • useActualizacionAutomatica() and useAvisosActualizacion(): auto-update loop and its toasts (see Settings and Shell).

There is no router. AppShell (src/features/shell/app-shell.tsx) switches on useUi().vista ("deck" | "biblioteca" | "disenar", type Vista in src/stores/ui-store.ts):

vista Content
deck Three resizable panels: DecklistPanel (left, Decklist Panel), PreviewPane (centre, Live Preview), ExportPanel (right, Export).
disenar DisenarScreen (Designer UI).
biblioteca LibraryScreen (Deck Library).

Dialogs (AddCardDialog, VariantDialog, VincularDialog, VincularLoteDialog, CardEditorDialog, MoverADialog, FichasDialog, StatsDialog, SetBrowserDialog, ColeccionesDialog, RecursosDialog, LoteDialog, PngDialog) are mounted once in App and driven by useUi().dialogo (a discriminated union Dialogo with tipo), opened with useUi.getState().abrir({ tipo: ... }). SincronizarTemas is also mounted here (loads installed themes from Rust). The Settings dialog is local state in AppShell.

Because stores and boot hooks live in App/AppShell-independent modules, switching view does not reset the deck, the plan or the autosave.

Panel layout

react-resizable-panels v4 is wrapped by src/components/ui/resizable.tsx (ResizablePanelGroup, ResizablePanel, ResizableHandle). Layouts are persisted by useDefaultLayout({ id, storage: localStorage }):

Layout id (localStorage) Owner
proxybuilder.paneles AppShell deck view: panels izquierda (default 22, min 260 px), centro (min 360 px), derecha (default 22, min 260 px).
proxybuilder.panel-lista Inside DecklistPanel.
proxybuilder.paneles.disenar DisenarScreen.

src/index.css widens the invisible grab zone of every [data-slot="resizable-handle"] to 12 px (the visible line stays 1 px) and keeps the grip dots always visible, so users discover the columns are resizable.

Shared components (src/components/ui)

These are shadcn "new-york" components (config in components.json, lucide icons, @/ alias) customised for the project. All use data-slot attributes; the skins and materials in src/styles/ target those slots.

Component Notes
button.tsx cva variants/sizes (xs, icon-xs, icon-sm...). Defaults type="button" so a button inside a <form> never submits unless it is the explicit submit button.
dialog.tsx Dialog* family. DialogContent implements the exit protocol (see below). DialogCuerpo is the scrollable body (uses ScrollArea). DialogFormulario, DialogCancelar, DialogClose.
salida-dialogo.ts SalidaDelDialogo context and useTrabajoEnElDialogo(activo, interrumpir): declare a running job inside a dialog.
formulario.tsx Formulario: the only allowed <form action>; cancels React 19's automatic form.reset() after an action (it would make Radix Checkbox/Switch revert to their mount value). Registers the running action with the dialog and passes an AbortSignal as the action's 2nd argument.
scroll-area.tsx Radix ScrollArea with WebKitGTK fixes (below). Props viewportClassName, viewportStyle, onViewportChange(el).
tooltip.tsx, with-tooltip.tsx Tooltip primitives and the WithTooltip helper.
resizable.tsx Panel group wrapper.
sonner.tsx Toaster; theme comes from useTheme().resolvedMode (no next-themes); every toast has an inside close button.
select.tsx, dropdown-menu.tsx, context-menu.tsx, popover.tsx, tabs.tsx, toggle.tsx, toggle-group.tsx, switch.tsx, slider.tsx, checkbox.tsx, input.tsx, progress.tsx, card.tsx, empty.tsx Standard primitives. input.tsx enables spell-check for plain text inputs unless the class contains font-mono.
grafico-barras.tsx, grafico-apilado.tsx Hand-rolled SVG charts used by Statistics (no chart library).

Non-ui shared components:

Component Purpose
src/components/campos.tsx Compact panel controls in house density: Seccion (collapsible, remembers open state in proxybuilder.seccion.<id>), Fila, CampoNumero (free typing, commit on blur/Enter, clamp + step rounding, arrow keys, Shift = x10), FilaInterruptor, Selector, FilaDeslizador (onSoltar fires on commit for expensive re-renders).
src/components/confirmar.tsx Confirmar: short confirmation dialog (destructiva variant, closes itself before calling onConfirmar).
src/components/limite-error.tsx Class error boundary with "Back" / "Reload window". Tells the user decks are safe in the local library.
src/components/tooltips-nativos.tsx Runtime net for native title (below).
src/components/smooth-scroll-client.tsx Mounts SmoothScroll according to the "Smooth scroll" setting.
src/components/theme-provider.tsx, language-provider.tsx Inherited providers; see Themes and Skins and Internationalization.

The dialog exit protocol

DialogContent (src/components/ui/dialog.tsx) decides whether leaving a dialog needs a confirmation. All four exits (Esc, click on the backdrop, the X, and exit buttons DialogCancelar/DialogClose) go through one function, salida.pedirSalida. It asks "Interrupt what is running?" if:

  • a Formulario action inside is running, or a job is declared via ocupado prop / useTrabajoEnElDialogo; on "yes" the action's AbortSignal fires; or
  • the user typed something: trusted input events and trusted clicks on checkbox/switch/radio inside this dialog count, except inside [data-filtro], sliders, ranges and input[type=search] (searching/moving is not "unsaved data"). Pass cambiosSinGuardar={false} for dialogs that stay open after saving or have nothing to lose (e.g. Confirmar, the Settings dialog).

Closing because a job finished (the action calls onCerrar) is parent state, not a user exit, so it never prompts.

src/lib reference

File Exports and role
ipc.ts Commands interface (command name to { args, result }, one entry per #[tauri::command]), call<C>(), Events interface (event name to payload, e.g. "resolve://progress", "export://done", "update://progress") and on(event, handler) which lazily imports @tauri-apps/api/event and returns the unlisten function. Adding a Rust command means adding its row here; the compiler then checks name and types at every call site.
errores.ts esAppError, mensajeDeError(e, t): Spanish text per ErrorCode, translated with t() and interpolated with AppError.params. See IPC and Error Handling.
image-url.ts URL builders for the pbimg:// protocol served by src-tauri/src/protocol.rs: urlMiniatura (r/<blob>/<b64url(PreviewParams)>/<width>), urlBusqueda (t/), urlDiseno (d/), urlCapa (c/), urlArte (b/), urlExtra (x/), raizProtocolo() (base used by theme fonts, f/). ANCHOS = [146, 244, 488, 672, 745, 1000]. The base comes from convertFileSrc("", "pbimg") so it is right per platform (pbimg://localhost/ on Linux/macOS, http://pbimg.localhost/ on Windows). parametros() turns PrintSettings + CardEdits into PreviewParams.
deck-ops.ts Pure list operations on DeckCard[]: nuevoId, mover, moverGrupo, duplicar, quitar, insertar, actualizar, huecos (blank cards for "Add page"). No React/Tauri so they are unit-tested directly.
cartas.ts Card helpers: imagenDorso, cartaPropia, nombreDeFichero (strips folder, extension, numbering like 0001 - , copy marks like (1)/~2), MARCAS (labels of DeckCard.marca), insignias(card) (CUSTOM, ↩ EN, LOWRES, PLACEHOLDER, MISSING, PROXY/BORRADOR design badges), listaParaReaplicar / aplicarReaplicado ("Reapply language").
trabajos.ts lanzarTrabajo(progressEvent, doneEvent, launch, onProgress): starts a long Rust job and wires its events (see below).
dialogos.ts File dialogs that remember folders: abrirParaImportar, guardarParaExportar, elegirCarpetaParaExportar, recordar. Only entry point for file dialogs. See Settings and Shell.
imagen-viva.ts Real-time preview helpers: CargaUltima (load one image at a time, only the last requested survives), useImagenViva, useRafaga (is a value changing faster than every 250 ms?), useAlRitmo (throttle with trailing value). See Live Preview.
themes.ts, tema-inicial.ts, temas-instalados.ts Theme catalogue and boot-time theme helpers; see Themes and Skins.
SmoothScroll.ts TypeScript port of "SmoothScroll for websites" (animates native scrollTop; not Lenis). Desktop-only gate: enabled for any non-mobile user agent, because the upstream Chrome/Safari check skipped some WebKitGTK user agents. puedeMoverseY is exported as a pure function.
utils.ts cn() (clsx + tailwind-merge).

Long jobs: lanzarTrabajo

src/lib/trabajos.ts solves a race: a short Rust job can emit its final event before the invoke that launches it returns its jobId. So the helper (1) subscribes to both progress and done events first, (2) launches, (3) buffers any events that arrive while jobId is still unknown, then replays/filters them by jobId, and (4) returns { jobId, fin } where fin resolves with the done payload (also when it carries an error). Callers (src/features/decklist/acciones.ts resolver, src/features/export/acciones.ts, png-dialog.tsx, lote-dialog.tsx, vincular-lote.tsx) mirror progress into useTrabajo (see State Stores) and cancel with job_cancel or export_cancel.

WebKitGTK and platform considerations

The Linux build runs on WebKitGTK, which behaves differently from Chrome; scrolling must be tested there. Concrete workarounds in code:

  • ScrollArea never re-renders its viewport when children change. Radix's Viewport renders a sibling <style> with dangerouslySetInnerHTML={{ __html }}; React 19 compares the object identity, so each render rewrote the stylesheet, and in WebKitGTK that forces a document-wide style recalculation (~50 ms), causing stutter when scrolling virtualised content. ScrollArea builds the viewport once with useMemo and passes real children through a context (HijosDelViewport / ContenidoDelViewport); style is compared by value to keep the memo stable.
  • ScrollBar uses forceMount: Radix would unmount the scrollbar 600 ms after scrolling stops and remount it on the next movement; that state change re-rendered the viewport and rewrote the <style> again (a ~120 ms frame on the first wheel tick after a pause). Hidden bars use data-[state=hidden]:pointer-events-none opacity-0.
  • The viewport has relative so absolutely positioned hidden inputs (Radix Checkbox form inputs) are measured against the scrolling element, not the non-clipping root (otherwise dialog bodies gained a huge empty scroll area).
  • Max heights go on the viewport (viewportClassName/viewportStyle); a max-height on the root does nothing because the root does not clip.
  • Native tooltips are drawn by GTK with the system theme and look foreign, hence the title ban.
  • Unlike Chrome, WebKit will not select text inside an input when an ancestor has user-select: none; src/index.css re-enables selection on input, textarea and [contenteditable].
  • UI scale uses root font-size (rem), never CSS zoom: zoom breaks position: fixed and Radix's getBoundingClientRect positioning in WKWebView (see Themes and Skins).
  • On a GPU-less display (CI/cloud) enter animations of dialogs do not advance and leave them invisible but click-blocking; run the app under Xvfb with WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 (not needed on a real desktop).

Scroll policy

Always use the shared ScrollArea (DialogCuerpo for dialog bodies). The only overflow-auto left in src/ are the error boundary's <pre> and Radix menu/select content (which have their own max-h rules). Lists whose items have a border or focus ring get p-1 padding so rings are not clipped by the scroll edge. ScrollArea.onViewportChange(el) hands out the real scrolling element; the preview pane, add-card dialog, set browser, library and others put it in the RaizScroll context, and useVisible(el) (src/features/preview/visibilidad.tsx) uses it as the root of an IntersectionObserver with rootMargin: "100% 0px" (one screen above and below). Off-screen pages keep their boxes but unmount their <img>, so WebKit releases the bitmaps and Rust only processes what is visible. The root must be the viewport, not the document, because the document itself never scrolls (see Live Preview).

Text-selection policy

src/index.css: body { user-select: none } (desktop-app feel); input, textarea, [contenteditable] get user-select: text; read-only "copy boxes" add the Tailwind select-text class (e.g. the error boundary's message). Interactive elements (button, [role=button], a[href], select, summary, label[for]) get cursor: pointer because Tailwind preflight sets cursor: default on buttons.

Native title ban (two layers)

  1. Static: scripts/sin-title-nativo.mjs scans every .tsx and fails if a lowercase HTML tag, or one of the pass-through components (Table*, Badge, Label, Button), has a title= attribute. Props like titulo= on our own components are fine. Runs in pnpm check and in the pre-commit hook.
  2. Runtime: TooltipsNativos (mounted in main.tsx) uses a MutationObserver on document.body to move any title into data-titulo-nativo (and aria-label if the element has no accessible name), then shows it with the house Tooltip anchored to an invisible fixed span positioned over the hovered element, with the same delay. iframe/frame are exempt (their title is the accessible name). In dev it logs a console warning for each conversion.

Tooltip also fixes a Radix quirk: with a tooltip open over a modal, the first Esc closed only the tooltip. TooltipContent re-dispatches the Escape to the layer below once the tooltip has really unmounted (reenviarEscape, only when a dialog/menu/listbox exists).

WithTooltip composition rule

WithTooltip renders <TooltipTrigger asChild> itself. When combined with another Radix trigger (DropdownMenuTrigger, PopoverTrigger, DialogTrigger...), the tooltip must be the outer wrapper (<WithTooltip><DropdownMenuTrigger asChild><Button/></DropdownMenuTrigger></WithTooltip>); the reverse breaks ref/handler merging and the menu stops opening. Empty/nullish content renders the child untouched.

Design conventions

Radius 14 px (--radius forced in src/index.css), density 10 px (p-2.5 / gap-2.5), base text text-xs, section titles text-[11px] uppercase. Details and the theme/skin system are in Themes and Skins; the full convention list is in Conventions. Feedback is toast from sonner (errors: toast.error(mensaje(e))).

Tooling configuration

File Content
package.json Scripts (dev, build = tsc && vite build, check, check:tipos, check:biome, check:title, idiomas, check:knip, test, bindings, check:clippy, check:rust-fmt, test:rust, release). pnpm@12 pinned in packageManager, engines.node >= 22. React 19, Vite 7, Vitest 5, Tailwind 4 (@tailwindcss/vite), zustand 5, react-resizable-panels 4, @dnd-kit/core, sonner, radix-ui, lucide-react.
vite.config.ts Plugins react() + tailwindcss(); alias @ to src; fixed port 1420 with strictPort; ignores src-tauri/** in the watcher (Cargo rebuilds it); clearScreen: false so Rust errors stay visible; optional TAURI_DEV_HOST for HMR over a LAN host.
vitest.config.ts environment: "node" (no DOM: tests stub localStorage themselves), restoreMocks, includes src/**/*.test.ts(x) and scripts/**/*.test.mjs.
tsconfig.json strict, noUnusedLocals/Parameters, noFallthroughCasesInSwitch, jsx: react-jsx, path alias @/*, include: ["src"]. tsconfig.node.json covers vite.config.ts.
biome.json Formatter: tabs, line width 100, double quotes, trailing commas. Linter recommended with many a11y rules turned off (this is a desktop app with custom controls) and useExhaustiveDependencies off. Ignores src/bindings, src/index.css, src/themes.css, src/fonts.css, src/styles, plans, tmp, dist. CSS formatter disabled.
knip.json Dead-code check (pnpm check:knip): unused files/dependencies/binaries; exports/types rules off; ignores src/bindings/**.
components.json shadcn CLI settings (style new-york, cssVariables: true, aliases).
pnpm-workspace.yaml minimumReleaseAge: 1440 (only versions older than a day) and allowBuilds: esbuild. Regenerate the lockfile with the pinned pnpm version, otherwise a different pnpm may resolve brand-new releases that the maintainer's install then rejects.
lint-staged.config.mjs + .husky/pre-commit On commit: biome check --write on TS, biome format --write on JSON/HTML, bare rustfmt --edition 2021 on staged .rs files (never cargo fmt, which would reformat untouched files); then project-wide tsc --noEmit, pnpm idiomas, pnpm check:title, and pnpm check:clippy if Rust changed.

See Testing and CI for how these run in CI, and Development Setup for first-time setup.

Tests that guard the frontend foundations

src/lib/__tests__/: cartas.test.ts, deck-ops.test.ts, dialogos.test.ts, errores.test.ts, imagen-viva.test.ts, image-url.test.ts, tema-inicial.test.ts, temas-instalados.test.ts, trabajos.test.ts. Store tests are listed in State Stores. There are no component (DOM) tests: the Vitest environment is node.

Where to start when extending

  • New Rust command: add the row to Commands in src/lib/ipc.ts, export the types with #[derive(TS)] #[ts(export)] and run pnpm bindings.
  • New Rust event: add it to Events in ipc.ts; for a long job use lanzarTrabajo.
  • New dialog: build it from Dialog* (DialogCuerpo for scrolling, DialogFormulario for forms), add a Dialogo variant in ui-store.ts and mount it in App.tsx.
  • New text: t("Spanish source") plus the English entry, see Internationalization.
  • New persisted preference: see State Stores for the keys already in use.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally