Repository navigation
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.
| 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
|
A command that takes more than an instant returns a numeric job_id immediately and works in spawn_blocking:
-
let (job_id, cancelado) = state.jobs.alta();(a cancel flag,jobs.rs). - Inside the task,
let _marca = estado.trabajo();marks the app busy for as long as the guard lives (AppState::ocupado()). - Progress goes out as
area://progressevents and the end asarea://done, both carryingjob_id;job_cancel/export_cancelflips the flag. - The frontend uses
lanzarTrabajo(progreso, final, lanzar, alProgreso)fromsrc/lib/trabajos.ts, which subscribes before launching (a short job can finish before theinvokereturns 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.
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.
-
All UI text goes through
t()with the Spanish source string as key; each translation goes insrc/i18n/<code>/<area>.jsonof every interface language (English is the reference; the terms followsrc/i18n/GLOSARIO.md).pnpm idiomasfails if one is missing, if a key is duplicated across area files, or if placeholders or plural forms do not match. Counts passed tot()are numbers (notoLocaleString/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 insrc/lib/errores.ts). - Variables use
{name}placeholders:t("Descargando la {version}…", { version }). - The factory language is English (
localStoragekeyproxybuilder.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 indocs/presentacion/en/, Spanish indocs/presentacion/).
Details: Internationalization.
| 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) |
-
No OpenSSL:
reqwestusesrustlswithdefault-features = false; CI fails ifcargo tree -i opensslfinds anything. -
pnpm 12, not npm, with
minimumReleaseAge: 1440andallowBuildsinpnpm-workspace.yaml; regenerate the lockfile with thepackageManagerversion. -
rusqliteis built withbundledSQLite so all three platforms behave the same.zipuses only deflate (pure Rust). OCR usesocrs(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.
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"]
-
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. -
The command. In
src-tauri/src/commands/<area>.rs:#[tauri::command], takeState<'_, AppState>(andAppHandlefor events), returnAppResult<T>. Convert core errors intoAppErrorwith a code and.with("detalle", ...)params. For long work use the job pattern above. -
Register it in the
tauri::generate_handler![...]list insrc-tauri/src/lib.rs. Forgetting this compiles but fails at runtime with an unknown command. -
Types: add
#[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)]to new request/response types (use#[ts(type = "number")]foru64fields, asActualizacionProgressEventdoes), then runpnpm bindings. -
src/lib/ipc.ts: import the generated type and add the command to theCommandsinterface (args,result), and any event toEvents. -
State: a Zustand store action that
call()s the command, or a pure helper next to the feature (testable). Errors are kept as rawAppErrorand translated at the edge withmensaje(e). Mock@/lib/ipcin the store's test, assrc/stores/__tests__/actualizacion-store.test.tsdoes. -
The component in
src/features/<area>/: sharedui/primitives, density and text size from the design rules,ScrollAreafor scroll,WithTooltipfor hints,t("Spanish text")for every string. -
Translations in the matching
src/i18n/<code>/<area>.jsonof every interface language (English first, as the reference). -
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.
- Schema changes go in a new numbered SQLite migration (names like
0003,0004appear in the codebase); some per-deck columns (pinned, notes, cover, language override) deliberately do not touchupdated_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 Rustapp_settings(app_setting_get/app_setting_set); layout widths usereact-resizable-panelsstorage.
- Heavy work is in Rust; the frontend only calls
call(). - No
unwrap/expectoutside tests; errors areAppErrorcodes. - New Rust types exported,
pnpm bindingsre-run,ipc.tsupdated. - Every string in
t(), entry added in every language, no duplicate key across areas,pnpm idiomasclean. - No
title=, nooverflow-auto, no bareinvoke, lists with rings havep-1. - Anything that blocks on a long job checks
ocupado()/ handlesBusy. - A new dialog with a long process or unsaved state calls
useEstadoParaCerrar; a new Tauri window API used from the WebView has its permission incapabilities/default.json. - Network use goes through
Scryfallorfuentes::Externo. - Both READMEs updated if user-facing behaviour changed.
- Left uncommitted and unpushed when working as an automated contributor.
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
- 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