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 (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. |
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). -
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. - Meanwhile (without waiting for the dictionary or a paint)
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" | "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.
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. 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. |
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.
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.
| 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). |
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.
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).
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, 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.
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.
- 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 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.
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