Skip to content

Conventions

Pablo Martínez López edited this page Oct 23, 2026 · 6 revisions

Conventions

English · Español

The rules the codebase follows, each with its reason, and how a typical change travels from Rust to the screen. Most rules are enforced by a gate (Biome, clippy, scripts/*.mjs, tests); the rest are review conventions marked with the hard-rule emoji convention in source comments.

Where a rule is enforced by tooling, the tool is named so you can see exactly what it will reject. See Testing and CI for the gates and Development Setup for how to run them.

Architecture rules

Rule Why Enforced by
Everything heavy lives in Rust: list parsing, Scryfall, downloads, cache, page geometry, image processing, PDF, statistics. React only draws and keeps screen state. One implementation, native speed on all cores, deterministic output, and a webview that never touches the network. Review; the CSP in src-tauri/tauri.conf.json allows connect-src only to ipc:; the capability file has no fs permission
One geometry for screen and paper. PagePlan (millimetres) is computed by proxy-core::layout and consumed by both the preview and the PDF. The screen never recomputes positions. What you see must be what prints. A second geometry in TypeScript would drift. Review; the plan is fetched by compute_page_plan into src/stores/plan-store.ts
Deck images live in the blob store (app_data_dir/blobs/, addressed by SHA-256), not in the cache. The cache (app_cache_dir/renders/) holds only derivatives and can be deleted at any time. Decks depend on their images; a cache clear must never break a deck. BlobStore::gc (src-tauri/src/store/blobs.rs); test vaciar_la_cache_no_toca_las_imagenes_de_los_decks in src-tauri/src/commands/library.rs
The preview unmounts images of pages that are not visible (IntersectionObserver whose root is the viewport of the shared ScrollArea). Decks of hundreds of cards would otherwise hold hundreds of decoded images. Review; see Live Preview
proxy-core is pure: no Tauri, no network, no database; it has its own error types and the app converts them to AppError. Fast tests (cargo test -p proxy-core in seconds), reuse, and a hard boundary against side effects. src-tauri/crates/proxy-core/Cargo.toml simply has no such dependencies
src-tauri/src/lib.rs only boots and registers commands; each area has src-tauri/src/commands/<area>.rs. It must not grow. A previous sibling project's lib.rs reached ~12k lines. Review
Errors are codes, never prose. Commands return Result<T, AppError> ({ code, params }); the screen translates with useLang().mensaje(e). Rust never writes user-facing text. The UI can be Spanish or English; one place (src/lib/errores.ts) owns the wording. Type system: AppError/ErrorCode in src-tauri/src/error.rs; see IPC and Error Handling
Shared types come from Rust: #[derive(TS)] #[ts(export)], then pnpm bindings writes src/bindings/. Never hand-edit those files. A single source of truth for the wire format. .cargo/config.toml (TS_RS_EXPORT_DIR); Biome and Knip ignore the folder
The screen talks to Rust only through src/lib/ipc.ts (call() and on()), never a bare invoke. The Commands interface makes the compiler check command names and types. Review; only ipc.ts imports invoke
The web view never goes to the internet. All network is Rust. All external downloads go through fuentes::Externo (closed host+path allow-list, redirects re-checked, size cap, images always decoded and re-encoded). Security and privacy; the CSP forbids it anyway. Decoding/re-encoding strips anything hostile from third-party files. CSP; src-tauri/src/fuentes/ (see External Sources)
File dialogs only through src/lib/dialogos.ts (abrirParaImportar, guardarParaExportar). They remember the last import and export folders separately (stored in Rust: commands/carpetas.rs, app_settings). Review
Long jobs follow one pattern (below). The UI can show progress, cancel, and block conflicting actions. src-tauri/src/jobs.rs, src/lib/trabajos.ts
Image framing stretches to the exact slot (imaging::frame) and crops only if the proportion differs by more than 15 % (MAX_ESTIRADO). Prefer a slight distortion over losing artwork or leaving gaps. Tests in imaging/frame.rs

The long-job pattern

A command that takes more than an instant returns a numeric job_id immediately and works in spawn_blocking:

  1. let (job_id, cancelado) = state.jobs.alta(); (a cancel flag, jobs.rs).
  2. Inside the task, let _marca = estado.trabajo(); marks the app busy for as long as the guard lives (AppState::ocupado()).
  3. Progress goes out as area://progress events and the end as area://done, both carrying job_id; job_cancel/export_cancel flips the flag.
  4. The frontend uses lanzarTrabajo(progreso, final, lanzar, alProgreso) from src/lib/trabajos.ts, which subscribes before launching (a short job can finish before the invoke returns its id; early events are buffered by id).

Being busy blocks update_install and blob_gc with ErrorCode::Busy. Example: export_pdf in src-tauri/src/commands/export.rs.

Design system (inherited from the sibling project)

The theme system, skins (Hera, Zeus), materials, src/themes.css (tweakcn), src/lib/themes.ts and src/components/theme-provider.tsx are carried over unchanged, so their conventions apply:

Rule Why
Radius 14 px, density 10 px (p-2.5, gap-2.5), base text text-xs Visual consistency; the app is dense by design
Scroll always uses the shared ScrollArea (src/components/ui/scroll-area.tsx), never overflow-auto in new code Consistent scrollbars and virtual-scroll behaviour in WebKitGTK; the IntersectionObserver root is its viewport
Lists whose items have a border or ring get p-1 padding So rings are not clipped by the scroll container
No native title=; use <Tooltip> or WithTooltip (src/components/ui/with-tooltip.tsx) GTK draws native tooltips with system colours. Enforced by pnpm check:title (scripts/sin-title-nativo.mjs)
WithTooltip must be the outer wrapper around another Radix *Trigger asChild Otherwise Slot ref/handler merging breaks and the dropdown or popover stops opening (documented at the top of with-tooltip.tsx)
Test scrolling and animations in WebKitGTK, not Chrome WebKitGTK is the real Linux webview and behaves differently
Text is not selectable (rule in src/index.css); only fields and copy boxes carry select-text It is a desktop app, not a document; WebKit refuses to select inside an input if an ancestor has user-select: none, so it is restored explicitly
Modals are 100 % opaque with any material or skin (rule at the end of src/index.css) With glass or matte materials and the Hera skin a dialog let the content behind show through and was hard to read; the rule at the end of src/index.css forces the card colour and removes backdrop blur for [data-slot="dialog-content"]
theme-provider.tsx is read-only; it only accepts themes present in THEMES; installed tweakcn themes are appended at startup by src/lib/temas-instalados.ts Keeps the inherited file untouched so it can be re-synced
A dialog with a long process or unsaved state of its own declares it with useEstadoParaCerrar({ enCurso, sinGuardar }) (src/lib/dialogos-abiertos.ts), using the same values it passes to DialogContent as ocupado / cambiosSinGuardar Closing the whole app asks about those dialogs (Settings and Shell). It is a hook per dialog because DialogContent cannot report it: files under src/components/ui/ are read-only (inherited), so they are not edited
Window starts hidden with a dark background; it is shown when the page has finished loading (on_page_load in src-tauri/src/lib.rs) and main.tsx also calls show() on the window Avoids a white flash

See Themes and Skins and Frontend Architecture.

Internationalization

  • All UI text goes through t() with the Spanish source string as key; each translation goes in src/i18n/<code>/<area>.json of every interface language (English is the reference; the terms follow src/i18n/GLOSARIO.md). pnpm idiomas fails if one is missing, if a key is duplicated across area files, or if placeholders or plural forms do not match. Counts passed to t() are numbers (no toLocaleString/toFixed): t() formats them and picks the plural form.
  • Texts that live in data tables and are translated elsewhere are wrapped in paraTraducir("...") (identity function) so the checker sees them (for example the error texts in src/lib/errores.ts).
  • Variables use {name} placeholders: t("Descargando la {version}…", { version }).
  • The factory language is English (localStorage key proxybuilder.lang); t() falls back to the Spanish key when no translation exists, which is why the gate matters.
  • The README is English with a Spanish copy, README.es.md: any change goes to both, with the same sections and screenshots (English screenshots in docs/presentacion/en/, Spanish in docs/presentacion/).

Details: Internationalization.

Code style

Aspect Convention
Indentation Tabs in all TS/JS/JSON/CSS (Biome, .editorconfig); four spaces in Rust (rustfmt). Why: tabs are one byte per level; rustfmt reformats whole files with its own default, so fighting it would make editor and cargo fmt --check contradict each other
Rust formatting in hooks rustfmt --edition 2021 on staged files; never cargo fmt in lint-staged (see Development Setup)
Lints Clippy with -D warnings. No unwrap()/expect() outside tests (#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] in both Rust crates). Use ?, ok_or_else, or AppError
Allowing a lint Only locally with #[allow(clippy::...)] and a comment saying why; never --no-verify
Language of code Comments, doc comments and commit messages are in Spanish. Wire/domain types that cross the boundary use English field names in camelCase (#[serde(rename_all = "camelCase")]), e.g. DeckCard, PagePlan, PrintSettings; newer areas use Spanish identifiers (actualizar, fuentes, diseno, biblioteca, Actualizador, ActualizacionInfo). Follow the surrounding module
Hard-rule marker Comments that state a non-negotiable constraint begin with the stop-sign emoji; grep for it to find the invariants of a file
Command names snake_case, <area>_<action>: deck_save, card_search, update_check, design_save
Event names <area>://progress and <area>://done: export://progress, identify://done, update://progress
Stores Zustand, one per concern in src/stores/; selectors by field (useUi((s) => s.actualizarSola))
Feature folders src/features/<area>/, with pure logic split from components into .ts files so Vitest can test it without rendering
Frontend path alias @/ is src/
Unused code Knip fails on unused files/dependencies; src/bindings/ is exempt
Defaults to remember Factory print values: 2 mm spacing, no bleed, cut guides off, "fill corners" off (transparent corners print as paper colour; corners::fill runs only when enabled)

Dependencies and platform

  • No OpenSSL: reqwest uses rustls with default-features = false; CI fails if cargo tree -i openssl finds anything.
  • pnpm 12, not npm, with minimumReleaseAge: 1440 and allowBuilds in pnpm-workspace.yaml; regenerate the lockfile with the packageManager version.
  • rusqlite is built with bundled SQLite so all three platforms behave the same. zip uses only deflate (pure Rust). OCR uses ocrs (pure Rust); models are downloaded on first use, not shipped.
  • Content that should not ship in installers (Card Conjurer "Original" frames, downloadable packs such as Balatro, free fonts, tweakcn theme fonts) is downloaded on demand with a pinned commit and a SHA-256 per file and stored under app_data_dir. See Frame Packs.

How a typical change flows

Adding a feature that needs data from Rust, from the bottom up:

flowchart TD
    A["1. proxy-core: pure logic + unit tests"] --> B["2. app crate: commands/area.rs command"]
    B --> C["3. register in lib.rs generate_handler"]
    C --> D["4. derive TS on new types, pnpm bindings"]
    D --> E["5. src/lib/ipc.ts Commands entry"]
    E --> F["6. store action (src/stores) or pure helper + vitest"]
    F --> G["7. component in src/features/area, text via t()"]
    G --> H["8. Translations in src/i18n/code/area.json"]
    H --> I["9. pnpm check + Rust gates"]
Loading
  1. Pure logic first. If it is deterministic (parsing, geometry, stats, imaging), put it in src-tauri/crates/proxy-core/src/<module> with #[cfg(test)] tests (a golden if it is visual; see Testing and CI). Give it its own error type.
  2. The command. In src-tauri/src/commands/<area>.rs: #[tauri::command], take State<'_, AppState> (and AppHandle for events), return AppResult<T>. Convert core errors into AppError with a code and .with("detalle", ...) params. For long work use the job pattern above.
  3. Register it in the tauri::generate_handler![...] list in src-tauri/src/lib.rs. Forgetting this compiles but fails at runtime with an unknown command.
  4. Types: add #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] to new request/response types (use #[ts(type = "number")] for u64 fields, as ActualizacionProgressEvent does), then run pnpm bindings.
  5. src/lib/ipc.ts: import the generated type and add the command to the Commands interface (args, result), and any event to Events.
  6. State: a Zustand store action that call()s the command, or a pure helper next to the feature (testable). Errors are kept as raw AppError and translated at the edge with mensaje(e). Mock @/lib/ipc in the store's test, as src/stores/__tests__/actualizacion-store.test.ts does.
  7. The component in src/features/<area>/: shared ui/ primitives, density and text size from the design rules, ScrollArea for scroll, WithTooltip for hints, t("Spanish text") for every string.
  8. Translations in the matching src/i18n/<code>/<area>.json of every interface language (English first, as the reference).
  9. Gates: pnpm check, pnpm check:rust-fmt, pnpm check:clippy, pnpm test:rust.

Worked example: the updater. src-tauri/src/actualizar.rs (logic and plugin wrapper) then src-tauri/src/commands/actualizar.rs (four commands, progress event update://progress, Busy guard) then registration in lib.rs then src/lib/ipc.ts entries then src/stores/actualizacion-store.ts then features/shell/actualizacion-automatica.tsx and features/settings/actualizaciones.tsx then strings in src/i18n/en/. See Releases and Auto-Updates.

Persisted data

  • Schema changes go in a new numbered SQLite migration (names like 0003, 0004 appear in the codebase); some per-deck columns (pinned, notes, cover, language override) deliberately do not touch updated_at, so cosmetic edits do not reorder "modified" sorting. See Storage and Blobs.
  • New fields on stored deck types are optional (added as optional fields or optional keys, not as new enum variants) so older app versions keep opening newer decks. Example: CardSource::Custom { scryfallId?, externa? }. See Deck Model and Deck Package Format.
  • User preferences live in ui-store (persisted selectively) or in Rust app_settings (app_setting_get/app_setting_set); layout widths use react-resizable-panels storage.

Review checklist

  • Heavy work is in Rust; the frontend only calls call().
  • No unwrap/expect outside tests; errors are AppError codes.
  • New Rust types exported, pnpm bindings re-run, ipc.ts updated.
  • Every string in t(), entry added in every language, no duplicate key across areas, pnpm idiomas clean.
  • No title=, no overflow-auto, no bare invoke, lists with rings have p-1.
  • Anything that blocks on a long job checks ocupado() / handles Busy.
  • A new dialog with a long process or unsaved state calls useEstadoParaCerrar; a new Tauri window API used from the WebView has its permission in capabilities/default.json.
  • Network use goes through Scryfall or fuentes::Externo.
  • Both READMEs updated if user-facing behaviour changed.
  • Left uncommitted and unpushed when working as an automated contributor.

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