Skip to content

Frontend Architecture

Pablo Martínez López edited this page Oct 29, 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 (four views) plus every global dialog; starts the boot hooks.
src/components/ Shared, non-feature components (providers, Confirmar, EstadoCarga, campos.tsx, LimiteError, TooltipsNativos, SelectorIdioma, Emoji/Bandera).
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, idiomas.ts, por-idioma.ts, themes.ts, tema-inicial.ts, temas-instalados.ts, use-ocupado.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, tutorial (the guided tours, see Tutorials), mesa (the game table, see Game Table).
src/i18n/ Interface dictionaries, one folder per language (<code>/, one JSON file per area, merged by the loader), the pure translator traducir.ts, the lazy loader cargar.ts, the types tipos.ts and the translators' glossary GLOSARIO.md. See Internationalization.
src/assets/emoji/ The Noto Color Emoji images the UI uses (flags of the language picker), with fuentes.json and LICENSE.md. See "Emoji and flags" below.
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. cargarDiccionario(leerIdioma()) (src/i18n/cargar.ts) and, when it settles (finally), montar() renders the provider tree (below). Only English is in the main bundle; awaiting the chosen language's chunk before the first render avoids a flash of English text. If it fails to load, the app mounts anyway and the strings fall back to English.
  5. Meanwhile (without waiting for the dictionary or a paint) 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" | "mesa", 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).
mesa MesaScreen (Game Table): the entrance or the table. The deck name is hidden in the header on this view, as on the Library. Leaving it keeps the game open in Rust and saves it.

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. The one exception to "switching is free" is the Design view: leaving it with the card unsaved asks first, because DisenarScreen unmounts and its draft would be lost (cambiarVista in AppShell, Settings and Shell). AppShell also mounts ConfirmarCierre, the guard that asks before the window closes.

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. This folder is read-only and is the only place that still imports lucide-react; everything else draws icons with <Icono> (see Icons). 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/contenido-contextual.tsx ContenidoContextual: ContextMenuContent that ignores pointerup/click for 250 ms after opening, so the right-click release that opened the menu does not trigger the item under the cursor. The opening time is set by a stable ref callback when Radix mounts the content node, not when the wrapper renders (the wrapper renders with the menu closed). Used by the slot menu, the deck menu and the game table menus.
src/components/confirmar.tsx Confirmar: short confirmation dialog (destructiva variant, closes itself before calling onConfirmar). Optional accionAlternativa: { texto, onClick } adds a third, safe button on the right ("Save and continue"): it is the focused one, variant default, and the main action stays in the middle (outline unless destructiva). onClick returns `Promise
src/components/estado-carga.tsx EstadoCarga: what a list shows while it loads (a spinner and «Cargando…») or when it failed (mensaje(error) and «Reintentar»); it takes the height through className so the content that replaces it does not make the dialog jump. Used by the game history lists.
src/lib/use-ocupado.ts useOcupado() → [ocupado, envolver]: for buttons that start something slow (create or enter a room, invite, start). envolver(f) sets ocupado while f runs and ignores a new call until it ends, so a double click does not do it twice.
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.
src/components/selector-idioma.tsx SelectorIdioma: a Select of languages (flag + native name, each item with its own lang attribute), defaulting to all of IDIOMAS. Used for the interface language (Settings) and the design language (export panel, Diseñar preview).
src/components/emoji.tsx Emoji, Bandera, urlEmoji, nombreNoto, banderaDe: emoji and flags as Noto images (below).
src/components/icono.tsx Icono: any Iconify icon by id (lucide:crown, game-icons:crossed-swords), bundled or fetched through Rust; see Icons.
src/components/selector-icono.tsx SelectorIcono: the Iconify icon picker (collection, «Todas las colecciones» search, filter, paging); see Icons.

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.

The exit protocol only covers closing one dialog. Closing the whole app is a separate guard (ConfirmarCierre, Settings and Shell) that needs to know which open dialogs have a running process or unsaved typing. DialogContent cannot report that itself: src/components/ui/ files are read-only (inherited, not edited), so each dialog that cares declares it with useEstadoParaCerrar({ enCurso, sinGuardar }) from src/lib/dialogos-abiertos.ts, passing the same values it gives DialogContent as ocupado / cambiosSinGuardar. Convention for a new dialog with a long process or its own unsaved state: pass both to DialogContent and call the hook in the component that renders it.

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; MOTIVOS gives a text of its own to each known params.motivo of Rejected (game table) and params.code of Server. 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/), urlMesa (m/, game table images; null for the generic back, which the screen paints), 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).
cierre.ts pendientes(deckSinGuardar) / hayPendientes(p): what would be lost or interrupted by closing the window (running job, update in progress, busy or unsaved dialog, unsaved card, deck that failed to save, a game open on the table). Reads the stores; used by ConfirmarCierre. See Settings and Shell.
dialogos-abiertos.ts Registry of open dialogs' own state for the close guard: registrarDialogo, estadoDeLosDialogos, hook useEstadoParaCerrar({ enCurso, sinGuardar }).
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.
idiomas.ts The single table of the 15 languages: LANGS, type Lang, IDIOMAS (codigo, nativo, pais, locale, impresas), esLang, idioma(codigo) (unknown code: English), localeDe(lang) and IDIOMAS_SCRYFALL (the 11 with printed cards, English first, Portuguese with its short name). Codes are Scryfall's (pt, zhs, zht) so interface, design and deck languages share keys; the BCP-47 tag lives in locale and is what every Intl call and <html lang> use. See Internationalization.
por-idioma.ts Reading and writing Rust's ByLang<T> beyond es/en: porIdioma (falls back to en, like ByLang::get), tieneIdioma, conIdioma, textosDe (no English fallback for other languages, like FaceDesign::textos) and sinTextoEn. Needed because ts-rs cannot flatten the generic map, so the binding only shows es and en. See Designer Engine.
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, completarIdioma in src/features/disenar/renumerar.ts, conProgreso in src/features/mesa/conexion.ts) mirror progress into useTrabajo (see State Stores) and cancel with job_cancel or export_cancel.

Emoji and flags

The UI never relies on the system emoji font for what it paints itself. src/components/emoji.tsx maps an emoji to a Noto Color Emoji image in src/assets/emoji/ (import.meta.glob, file name emoji_u<codepoints> without the U+FE0F variation selector); <Emoji> renders it at 1em, or the plain character if the app does not ship that image; <Bandera pais="ES"> builds the flag from two regional indicators. Why images and not a font: WebKit on macOS does not paint COLRv1 fonts, and Windows' Segoe UI Emoji has no flags, so the language picker would show letters instead of flags there.

The images are copied by scripts/emoji-noto.mjs (the flags of src/lib/idiomas.ts plus any emoji passed as arguments; --desde <clone> works offline) from a fixed commit of googlefonts/noto-emoji: flags from third_party/region-flags/waved-svg/ (public domain), the rest from 2D/svg/ (Apache 2.0, LICENSE.md). fuentes.json records the commit and each file's source path and SHA-256. An SVG over 64 KB (Spain's flag, because of its coat of arms) is stored as a 128 px wide PNG converted with Inkscape, since at 1em the detail is invisible. Where a symbol is just an icon, an <Icono> is used instead (the count of designed cards in the library list carries a lucide:pencil, not a ✎ character).

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, iconos, check:iconos, 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 (only in components/ui/), @iconify/utils; dev @iconify/json (read by scripts/iconos.mjs).
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, idiomas.test.ts (the 15 languages in LANGS order, canonical BCP-47 locales, the 11 Scryfall languages, unknown code falls back to English), imagen-viva.test.ts, image-url.test.ts, tema-inicial.test.ts, temas-instalados.test.ts, trabajos.test.ts. src/i18n/__tests__/traducir.test.ts covers the fallback chain, plurals and number formatting; src/components/__tests__/emoji.test.tsx checks that every language's flag has an image (rendered with react-dom/server, no DOM). Store tests are listed in State Stores. There are no DOM component 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 an entry in every language's dictionary, see Internationalization.
  • New emoji in the UI: node scripts/emoji-noto.mjs <emoji> and render it with <Emoji>.
  • 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