Repository navigation
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).
| 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. |
| 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. |
src/main.tsx does, in order:
- In production only (
!import.meta.env.DEV), a bubblingcontextmenulistener callspreventDefault()unless the event is alreadydefaultPrevented(so Radix context menus on cards still work, but the WebView's "Reload / Inspect" menu never shows). -
prepararTemasInstalados()(src/lib/temas-instalados.ts): injects the CSS of installed tweakcn themes and appends them toTHEMESbefore React mounts, becauseThemeProvideronly accepts a stored theme that is inTHEMES. -
ponerTemaAntesDeMontar()(src/lib/tema-inicial.ts): writesdata-theme, thedarkclass and the skin/background/material attributes on<html>so the very first paint already has the right colours (no white flash). - Renders the provider tree (below).
-
ensenarVentana()callsgetCurrentWindow().show(). The window is created hidden with a dark background intauri.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 onPageLoadEvent::Finishedand a 3-second fallback thread insrc-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, ..."]
Why this order:
-
LimiteErrorwraps everything so a render error shows a recovery screen instead of a blank window. Because it sits outsideLanguageProvider, it cannot useuseLang(); it usestraduccionSuelta(same dictionary, readsproxybuilder.langdirectly). -
LanguageProvideris outsideThemeProviderbecausetema-inicial/theme code needs no language, but any dialog needs both. -
TooltipProviderenclosesAppso everyTooltipshares one delay (RETARDO_TOOLTIPS, 700 ms, defined only insrc/components/ui/tooltip.tsx;skipDelayDurationis forced to 0 so Radix never opens a second tooltip instantly).
App() calls three hooks and renders AppShell plus the always-mounted dialogs:
-
useDeckInicial()(src/features/shell/use-deck-inicial.ts): startsiniciarAutoguardado()andiniciarPlan(), opens the last deck (or creates one), re-renders stale design thumbnails when a deck opens (rehacerMiniaturas), and installs the globalCtrl/Cmd+Z/Ctrl/Cmd+Shift+Zundo/redo (ignored inside inputs, textareas andcontenteditable). -
useActualizacionAutomatica()anduseAvisosActualizacion(): 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.
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.
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. |
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
Formularioaction inside is running, or a job is declared viaocupadoprop /useTrabajoEnElDialogo; on "yes" the action'sAbortSignalfires; or - the user typed something: trusted
inputevents and trusted clicks on checkbox/switch/radio inside this dialog count, except inside[data-filtro], sliders, ranges andinput[type=search](searching/moving is not "unsaved data"). PasscambiosSinGuardar={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.
| 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). |
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.
The Linux build runs on WebKitGTK, which behaves differently from Chrome; scrolling must be tested there. Concrete workarounds in code:
-
ScrollAreanever re-renders its viewport when children change. Radix'sViewportrenders a sibling<style>withdangerouslySetInnerHTML={{ __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.ScrollAreabuilds the viewport once withuseMemoand passes real children through a context (HijosDelViewport/ContenidoDelViewport);styleis compared by value to keep the memo stable. -
ScrollBarusesforceMount: 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 usedata-[state=hidden]:pointer-events-none opacity-0. - The viewport has
relativeso absolutely positioned hidden inputs (RadixCheckboxform 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); amax-heighton 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
titleban. - Unlike Chrome, WebKit will not select text inside an input when an ancestor has
user-select: none;src/index.cssre-enables selection oninput,textareaand[contenteditable]. - UI scale uses root
font-size(rem), never CSSzoom:zoombreaksposition: fixedand Radix'sgetBoundingClientRectpositioning 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).
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).
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.
- Static:
scripts/sin-title-nativo.mjsscans every.tsxand fails if a lowercase HTML tag, or one of the pass-through components (Table*,Badge,Label,Button), has atitle=attribute. Props liketitulo=on our own components are fine. Runs inpnpm checkand in the pre-commit hook. - Runtime:
TooltipsNativos(mounted inmain.tsx) uses aMutationObserverondocument.bodyto move anytitleintodata-titulo-nativo(andaria-labelif the element has no accessible name), then shows it with the houseTooltipanchored to an invisible fixedspanpositioned over the hovered element, with the same delay.iframe/frameare exempt (theirtitleis 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 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.
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))).
| 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.
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.
- New Rust command: add the row to
Commandsinsrc/lib/ipc.ts, export the types with#[derive(TS)] #[ts(export)]and runpnpm bindings. - New Rust event: add it to
Eventsinipc.ts; for a long job uselanzarTrabajo. - New dialog: build it from
Dialog*(DialogCuerpofor scrolling,DialogFormulariofor forms), add aDialogovariant inui-store.tsand mount it inApp.tsx. - New text:
t("Spanish source")plus the English entry, see Internationalization. - New persisted preference: see State Stores for the keys already in use.
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Icons · Iconos
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia