Skip to content

Designer UI

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

Designer UI

English · Español

The React side of the card designer: the "Diseñar" view (third tab of the app, next to "Deck" and "Library") plus its dialogs (collections, fonts/frames/symbols, "Design the deck", "Export images", crop an artwork). It only paints and keeps screen state; every render, hash and file operation is done by Rust (Designer App Layer) and the pure engine (Designer Engine).

The designer view

Layout and entry points

src/features/disenar/disenar-screen.tsx → DisenarScreen is passed to the shell as the disenar slot (src/App.tsx → src/features/shell/app-shell.tsx, a ToggleGroup over useUi.vista = "deck" | "biblioteca" | "disenar"). The dialogs are mounted once in src/App.tsx (ColeccionesDialog, RecursosDialog, LoteDialog, PngDialog, CardEditorDialog) and opened through useUi.getState().abrir({ tipo }) with the Dialogo kinds colecciones, recursosDiseno, disenarDeck, exportarPng (src/stores/ui-store.ts).

Ways to enter: the "Diseñar" tab, or the card context menu item "Diseñar proxy" / "Editar diseño" (src/features/preview/slot-menu.tsx, enabled when the card has a Scryfall id: scryfallDe(carta) !== null), which calls useUi.disenar(id) (sets disenando and switches vista).

┌ top bar: ‹ › Alt+←/→ │ Renumber │ Design the deck │ Export images │ Collections │ Letters, frames and symbols
├───────────┬─────────────────────────────┬────────────────────────┐
│ ListaCartas│ VistaPrevia (ES/EN, face,   │ Formulario (6 tabs)    │
│ (groups)  │ safe margin, art overlay,   │ + footer: remove,      │
│           │ warnings)                   │ discard, Save, Save+next│
└───────────┴─────────────────────────────┴────────────────────────┘

The three panels are a ResizablePanelGroup (ids lista, vista, formulario); sizes persist in localStorage under proxybuilder.paneles.disenar (useDefaultLayout). Scrolling uses the shared ScrollArea (project rule: never overflow-auto), lists with ringed items use p-1 padding.

File map

File Role
disenar-screen.tsx Screen shell: selection, ES/EN language, face, resources, keyboard shortcut, save buttons
use-borrador.ts The editing hook: loads/creates the design, registers previews, warnings, save/remove
use-resumen.ts Debounced design_structure (layout kind and how text splits)
grupos.ts Grouping of deck cards by Scryfall card; aplicarAlDeck, quitarDiseno, antesDe, numerosUsados
lista-cartas.tsx Left list with status badges and search
vista-previa.tsx Centre: the card image, language/face toggles, live artwork dragging, warnings list
arte-en-vivo.tsx The overlay compositor used while dragging artwork
encuadre.ts Framing maths shared with Rust (moverEncuadre, geometriaArte, claveCapa)
formulario.tsx Right panel, 6 tabs; also exports EditorMarco (reused by collections)
ajustes-finos.tsx Fine-tuning controls (AjustesFinos, AJUSTES_CERO, hayAjustes) used by the form and collections
estilos.ts Built-in styles and pack-specific styles in one selector
textos.ts Label tables (styles, palettes, rarities, layouts) and textoAviso/esGrave
traduccion.tsx "Suggested translation" panel
colecciones-dialog.tsx Collections manager
recursos-dialog.tsx Fonts, frame packs (including downloadable), symbols
lote-dialog.tsx "Design the deck"
png-dialog.tsx "Export images"
recortar-dialog.tsx Draw a rectangle on a finished card to use as artwork
renumerar.ts renumerar, rehacerMiniaturas, aplicarCambiadas
marcos-originales.ts, packs-descargables.ts Small zustand stores for download state of "Original" frames and downloadable packs
src/lib/imagen-viva.ts Real-time image loading utilities (also used by Card Editor Dialog)

State model

There is no dedicated designer store. State is split by lifetime:

State Where Lifetime
The deck (cards, each with an optional design) useDeck (src/stores/deck-store.ts) The truth; undoable via setCartas
Which card is being designed (disenando), current tab (vista), open dialog useUi App session
lang (ES/EN of the on-screen preview), cara (face index), soloEsta, colecciones, packs, simbolos useState in DisenarScreen While the view is mounted
design, original, huella, avisos, cargando, guardando useBorrador Per selected card
Remembered collection for new drafts localStorage key proxybuilder.disenar.coleccion (coleccionRecordada/recordarColeccion) Per machine, wrapped in try/catch
Download progress of Original / downloadable packs useMarcosOriginales, usePacksDescargables App session

DisenarScreen initialises lang from deck.settings.designLang but never writes it back, so the ES/EN toggle only affects the preview (the PDF uses the deck's own designLang, see Designer App Layer).

Groups

agrupar(cartas) (grupos.ts) groups deck cards by Scryfall id (s:<id>), including tokens and custom cards linked to Scryfall (scryfallDe); blank slots are skipped; custom cards without a link go alone (c:<cardId>) and cannot be designed (disabled row with a tooltip pointing to "Link with a card…"). A design applies to all copies in a group unless "Solo esta copia (hay N)" is ticked. The state of a group is that of its first copy (estadoDe): sinVincular, sinDiseno, borrador, listo (design.status).

useBorrador: the editing loop

flowchart TD
  Sel["selected group changes"] --> Has{"principal.design?"}
  Has -->|yes| Load["design = original = principal.design"]
  Has -->|no| Draft["call design_draft(card, usados, collection)"]
  Draft --> Set["design = original = draft.design; avisos = draft.avisos"]
  Load --> Edit
  Set --> Edit["cambiar(f): setDesign(f(d))"]
  Edit --> Reg["effect: call design_register (one in flight, last wins)"]
  Edit --> Warn["effect: 400 ms debounce, design_fit_report"]
  Edit --> Struct["use-resumen: 200 ms debounce, design_structure"]
  Reg --> Hash["huella -> pbimg d/huella/ancho"]
  Edit --> Save["guardar: design_save -> aplicarAlDeck -> setCartas"]
Loading

Key behaviours (all in use-borrador.ts):

  • Turn counter (turno ref): responses from a previous card never overwrite the current one.
  • Unsaved draft vs original: cambiado = JSON.stringify(design) !== JSON.stringify(original). A fresh draft counts as unchanged ("Discard" returns to it). Choosing another card while cambiado asks a Confirmar dialog ("Discard the changes?").
  • External changes: if the deck's design for this card changes from outside (bulk drafts, undo, renumber, re-thumbnailing) and nothing is unsaved here, the new design is adopted.
  • Real-time registration: every change calls design_register as soon as the previous call finishes; if several arrive meanwhile only the last runs (registro.enVuelo/pendiente). No timer: the preview follows typing and sliders. The response is stored as { h, de: design }; alDia = huella.de === design tells the preview whether the hash belongs to the design as of now.
  • Resources signal: useBorrador(..., recursos) takes an opaque recursos value ({ colecciones, packs, simbolos }); when it changes (collections edited, packs downloaded, symbols fetched) the design is re-registered and warnings recomputed even though the design is identical.
  • Warnings (design_fit_report) wait 400 ms because Rust renders every face to compute them.
  • Save (guardar(todas, listo?)): calls design_save with the deck's designLang, applies the result with one setCartas(aplicarAlDeck(...)) so undo works, remembers the design's collection for the next drafts, and resets design/original to the saved design. With todas = true the other copies get the same design with their own before preserved (antesDe).
  • Remove: quitar uses the local quitarDiseno(cartas, ids) (restores design.before); the Rust command design_remove is registered but not used by the UI.

DisenarScreen extras:

  • Auto-selects the first designable card when entering with none selected.
  • Alt+← / Alt+→ (window keydown) move to the previous/next designable group (wrap-around); also the chevron buttons.
  • "Save and next" saves then pasar(1).
  • On entering, symbols_status({ refresh: false }) is called once: the first time it downloads Scryfall mana symbols (needs a connection), then bumps simbolos so the preview repaints; with 0 loaded it shows a warning toast.
  • Collections and packs lists are re-read when a dialog opens/closes or when a download finishes (versionOriginales, versionDescargables).

Preview (vista-previa.tsx)

The card shown is whatever Rust paints for the registered hash: <img src={urlDiseno(huella, ancho)}>.

  • Resolution: a ResizeObserver on the frame picks 1000 / 745 / 488 px from clientWidth × devicePixelRatio (>760, >500, else).
  • Burst mode: useRafaga(huella) is true while the hash changes more often than every 250 ms; the URL then asks for at most 488 px so it follows the cursor, and the full resolution comes when the burst ends.
  • Never blank: useImagenViva keeps showing the last loaded image until the next one has finished loading.
  • Controls: ES/EN toggle (lang), face toggle when the design has more than one face ("Face n" for printed faces, "Part n" for split/adventure parts, using resumen.carasImpresas), and a "Safety margin" switch drawing a dashed rectangle inset 3 mm from each edge (3/63, 3/88 percentages).
  • Warnings list under the card: textoAviso(a, t) maps each Aviso variant to translated text; esGrave (text does not fit, missing symbols) makes it amber with a warning icon.

Live artwork dragging

Problem: moving the artwork with a full Rust render per mouse move is too slow. Solution (Decision: render the card once without artwork and move the artwork with CSS):

sequenceDiagram
  participant U as Pointer / wheel
  participant VP as VistaPrevia
  participant R as Rust
  U->>VP: pointerenter / down / wheel
  VP->>R: design_overlay(design, cara, lang, ancho)
  R-->>VP: CapaInfo { huella, arte[x,y,w,h], radio, cara }
  VP->>VP: ArteEnVivo mounts: art img (/b/blob/1000|2000) + overlay img (/c/huella/ancho)
  U->>VP: move (dx, dy) / wheel (zoom)
  VP->>VP: moverEncuadre -> vivo (CSS, instant)
  VP-->>VP: onEncuadre pushed to design every 100 ms
  U->>VP: release / 200 ms after last wheel tick
  VP->>R: normal design_register path renders the exact card
Loading

How it fits together:

  • claveCapa(design, cara, lang) (encuadre.ts) is the JSON of the design with every artwork's framing nulled. While the key is unchanged the overlay layer stays valid; on a change of anything else (text, frame, language) a new layer is requested lazily. pedida ref ("<clave>|<ancho>") prevents duplicate requests and discards stale answers. A change of recursos clears the layer.
  • The layer is requested on pointerenter, pointerdown, wheel, and when the framing changes through the form's sliders (framingClave effect).
  • ArteEnVivo (arte-en-vivo.tsx) is always mounted while a valid layer exists, so both images are already loaded when needed; visible toggles it. Structure: black rounded container (borderRadius from capa.radio as percentages) → the artwork <img> inside a box positioned at capa.arte (percentages of the card) with width/height/transform: translate(...) rotate(...) computed by geometriaArte → the overlay <img> on top. It reports onLista(true) when both images are loaded.
  • moverEncuadre(framing, change, hole) (in mm: hole = capa.arte × 63×88 mm, or HUECO_TIPICO = [0.08, 0.12, 0.84, 0.45] if no layer yet) returns the new framing: zoom clamped to 1–3, offsets in [-1, 1] scaled by the slack on each axis so that the artwork follows the pointer 1:1 until it reaches an edge, regardless of zoom or rotation. geometriaArte re-implements the artwork placement used by the engine's pintar_arte (same cover-the-hole maths with rotated bounding box); the unit tests in __tests__/encuadre.test.ts pin the geometry in TypeScript only (no cross-language parity test).
  • While dragging: vivo (local framing) is applied instantly; onEncuadre pushes it to the design at most every EMPUJE_MS = 100; on release (or FIN_RUEDA_MS = 200 ms after the last wheel tick) acabar() flushes the final value. During the drag the displayed URL is frozen (congelada ref) so intermediate Rust renders are not shown; componer = enVivo && (vivo !== null || mostrada !== exacta) keeps the overlay visible until the exact render (urlDiseno(huella, ancho) of the up-to-date hash) is on screen.
  • Plain wheel zooms by ±0.04 per tick (Ctrl+wheel is ignored). Dragging is disabled when the face has no artwork.
  • Composite layouts, big cards or faces without artwork: Rust returns null and the old path is used (each change repaints through /d/).

src/lib/imagen-viva.ts

Export Behaviour
CargaUltima Loads one image at a time; if more arrive meanwhile, only the last is kept; reports busy/idle
useImagenViva(url) Keeps the last successfully loaded URL (mostrada) until the new one loads; cargando flag
useRafaga(valor, ms=250) true while the value changes faster than every ms
useAlRitmo(valor, ms=60) Passes a value through at most every ms, always delivering the last
cargarEnNavegador Default loader (an Image object warms the browser cache)

Tests: src/lib/__tests__/imagen-viva.test.ts (CargaUltima).

The form (formulario.tsx)

Six tabs in a Tabs component; cambiar(d => d') is the only way to edit the design. All labels go through t() with Spanish source strings.

Tab Content
Carta (Card) Read-only original data (name, cost, type, P/T, rarity, set/number, detected layout via MAQUETACIONES), Partes (how the text splits), style selector, variant selector (pack styles with variantes), collection selector + "Manage collections…", "Ready to print" switch (status)
Textos (Texts) Per language (Spanish, English): themed name, flavour text, rules (official text shown read-only; "Write by hand" sets rulesOverride; "Restore the official" clears it); TraduccionSugerida; "Show flavour text" switch (options.showFlavor); any text may contain emoji, painted in colour by the engine (see Designer-Engine)
Arte (Art) Upload image / Paste / Scryfall art / Crop from a card; source and size; warning under 900 px width; sliders for zoom (1–3), horizontal/vertical offset (-1..1), rotation (-180..180); "Initial framing"
Marco (Frame) "Own frame for this card" switch (design.frame), EditorMarco, emblem (collection's / none / own), custom title squeeze
Pie (Footer) Collection number (typing sets numberManual), rarity letter, set code, artist, credit, universe, year, "original name" placement; empty means "from the collection" (placeholder shows the inherited value)
Ajustes finos (Fine tuning) Own fine-tuning for this card, or the collection's; "Use these settings for the whole collection"

Details worth knowing:

  • Artwork sources (PestanaArte): "Upload" opens the native file dialog through abrirParaImportar (src/lib/dialogos.ts), imports it with blob_import_files, then art_from_blob. "Paste" calls art_from_clipboard (Rust reads the clipboard because WebKitGTK denies navigator.clipboard.read()). "Scryfall art" calls art_from_scryfall for the card's scryfallId and current face. "Crop from a card…" opens RecortarDialog. Sliders and the preview drag write the same framing.
  • Styles (estilos.ts): the selector merges built-in ESTILOS (Borderless, Extended, Classic, Full art) with the pack's own styles. A pack style is stored as style (its built-in base, used as fallback if the pack lacks it) + estiloPack (its id), and shown as the value pack:<id>; elegirEstilo converts back. packEfectivo(design, colecciones) = card frame pack, else collection's, else "builtin". Changing the frame pack in the Marco tab clears estiloPack if the new pack lacks it.
  • Frame editor (EditorMarco, exported and reused by collections): pack selector, colour (auto / one palette colour / gradient / custom hex), legendary crown (auto/on/off), rules box (auto/dark/light/tint). Packs not yet installed appear as "Original (download)" and " (download)"; picking one starts the Rust download with a progress bar and then applies the pack.
  • Fine tuning (ajustes-finos.tsx → AjustesFinos): size of title/type/rules/P-T/footer (50–160 %), rules and footer line spacing (70–160 %), per-element vertical offset in mm (-3..3), extra opacity of the rules box, own radius for the artwork window and for the card corners. A 100 % size is stored as undefined; for rules, rulesSize === undefined means "automatic" (the toggle "Rules: automatic size"). hayAjustes reports whether anything differs from AJUSTES_CERO. "Own settings for this card" copies the collection's fine tuning as a starting point.
  • Pass to collection: PestanaAjustes.pasarAColeccion saves the card's fine tuning into its collection (collection_save), clears the card's own, and calls rehacerMiniaturas() so other cards' thumbnails update.
  • Suggested translation (traduccion.tsx): shown only when the card has no Spanish text (card.sinTextoEs) or its Spanish type line equals the English one. "Search official phrases" calls design_suggest_translation (slow: one Scryfall search per sentence); the result shows found sentences (green) vs. left in English (amber), with "Use" for the type line and "Use as Spanish rules" (sets rulesOverride on the Spanish text). The response is dropped if the card/face changed meanwhile (claveActual ref).

Dialogs

Dialog File Commands Notes
Collections colecciones-dialog.tsx collection_list/save/delete, fonts_list, packs_list, blob_import_files Sidebar list with per-row menu (duplicate, delete); editor sections General, Frame, Emblem, Fonts (title/rules family, weights 400–900), Footer (set code, rarity letter, artist, credit, universe, year, ES/EN footer templates with tokens {num} {rarity} {original} {set} {lang} {artist} {credit} {universe} {year} {brush} {star}, tab = column, newline = row, text after {>} right-aligned), Fine tuning. Closing with unsaved changes asks for confirmation (cambiosSinGuardar). On close, if anything was saved or deleted, rehacerMiniaturas() runs.
Letters, frames and symbols recursos-dialog.tsx fonts_list/import/remove, fonts_free_list, font_free_download, packs_list/import/remove, original_*, packs_downloadable_*, pack_extra_use, symbols_status Tabs Letters / Frame packs / Symbols. Imports .ttf/.otf, pack from folder or .zip; free OFL fonts catalogue with PNG samples from src/assets/letras/; downloadable pack rows with a "Contents" panel whose extras (backs, tokens, basic lands, finished cards) can be set as the deck back (useDeck.aplicar({ defaultBack })) or inserted as a custom card (cartaPropia + insertar). On close, rehacerMiniaturas() if anything changed.
Design the deck lote-dialog.tsx design_drafts_for_deck, job_cancel, events design://progress/done Scope all/selection, collection selector, progress bar, cancel. Results collected and applied to the deck in one setCartas even when cancelled. Network/rate-limit errors abort; per-card failures are counted and reported.
Export images png-dialog.tsx design_export_png, job_cancel, events designpng://progress/done 300/600/900 dpi, language ES/EN/both, folder via elegirCarpetaParaExportar; copies export once.
Crop artwork recortar-dialog.tsx art_from_card Lists deck cards that have an image and are not already designed, one per blob; drag a rectangle (initial guess x .08, y .11, w .84, h .45); must be at least 5 % per side; the result is a PNG blob.

All long jobs use lanzarTrabajo (src/lib/trabajos.ts): it subscribes to the progress and done events before launching so a fast job cannot lose its final event, and buffers events until the jobId is known.

Renumber and re-thumbnail (renumerar.ts)

  • renumerar(t, mensaje) → design_renumber → aplicarCambiadas (one history step) and an announcement ("N cards renumbered" / "Numbering already up to date").
  • rehacerMiniaturas() → design_rerender → useDeck.refrescarCartas(antes, nuevas): replaces only cards that are still identical to what was sent, marks the deck dirty without a history entry (it is not a user edit; if the user changed the card meanwhile their change wins). Called when a deck opens (src/features/shell/use-deck-inicial.ts) and when the collections/resources dialogs close, and after "pass to collection". Errors are swallowed (it will retry next time).

Internationalisation

Rules from the project: all text through t("Spanish source string"); English in src/i18n/en/disenar.ts (record keyed by the Spanish string, ~390 lines); label tables that live outside components use paraTraducir("…") (a no-op marker so pnpm idiomas sees the literal) and are translated with t() where rendered (textos.ts, GROSORES, TIPOS_EXTRA). pnpm idiomas (scripts/idiomas.mjs) fails when a requested string is missing, and a string must not appear in two area files. Rust never writes user prose: warnings arrive as Aviso variants and are turned into text by textoAviso; errors by useLang().mensaje(e). See Internationalization.

Tests

File Covers
src/features/disenar/__tests__/encuadre.test.ts moverEncuadre (1:1 following, bounds, zoom 1–3), claveCapa (ignores framing only), geometriaArte (centered, edges, aspect-only, 90° rotation)
.../estilos.test.ts built-in + pack styles in one selector; elegirEstilo
.../grupos.test.ts scryfallDe, agrupar, estadoDe, antesDe, numerosUsados, aplicarAlDeck, quitarDiseno
.../renumerar.test.ts aplicarCambiadas, renumerar, rehacerMiniaturas (errors, concurrent edits)
.../textos.test.ts every Aviso variant has text, esGrave, unique layout names
src/lib/__tests__/imagen-viva.test.ts CargaUltima

There are no component tests for the form or the preview; the live behaviour is checked by running the app. Run with pnpm check (Testing and CI).

Gotchas

  • Do not compute card geometry in the screen. The only geometry here is artwork placement during a drag, and it deliberately mirrors the engine's own computation; a final Rust render always replaces it.
  • design/original comparison is JSON.stringify, so key order matters: always spread existing objects rather than rebuilding them.
  • useBorrador's effects intentionally omit some dependencies (they key on card id/group key); adding them would reload the draft on every deck change.
  • Per-face state: cara resets to 0 when the selected group changes. In split/aftermath layouts the "faces" are parts of one printed face (carasImpresas === 1).
  • A huella from before the latest edit may arrive late; always check alDia before treating it as exact.
  • WithTooltip instead of native title= (checked by pnpm check:title); modals are fully opaque.

Where to start when extending

  • New form field: add the field to the Rust model (CardDesign/DesignOptions/FooterOptions, #[derive(TS)]), regenerate bindings (pnpm bindings), add a control in the right Pestana… function using cambiar, make sure the engine reads it (otherwise the hash will not change visibly), add Spanish + English strings.
  • New dialog: add a Dialogo kind in ui-store.ts, mount it in src/App.tsx, reuse lanzarTrabajo for jobs.
  • New warning: add the Aviso variant in Rust, a case in textoAviso (Inferred: the switch has no default, so the compiler should flag the missing case), and a test line in textos.test.ts.

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