Skip to content

Convenciones

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

Convenciones

English · Español

Las reglas que sigue el código, cada una con su motivo, y cómo viaja un cambio típico desde Rust hasta la pantalla. La mayoría de las reglas las hace cumplir una puerta (Biome, clippy, scripts/*.mjs, tests); el resto son convenciones de revisión marcadas en los comentarios del código con el emoji de regla dura.

Cuando una regla la hace cumplir una herramienta, se nombra la herramienta para que veas exactamente qué rechazará. Véase Tests y CI para las puertas y Preparación del entorno de desarrollo para saber cómo ejecutarlas.

Reglas de arquitectura

Regla Motivo La hace cumplir
Todo lo pesado vive en Rust: análisis de la lista, Scryfall, descargas, caché, geometría de página, procesado de imagen, PDF, estadísticas. React solo pinta y guarda el estado de pantalla. Una sola implementación, velocidad nativa en todos los núcleos, salida determinista y un webview que nunca toca la red. Revisión; la CSP de src-tauri/tauri.conf.json solo permite connect-src a ipc:; el fichero de capacidades no tiene permiso fs
Una sola geometría para pantalla y papel. PagePlan (milímetros) lo calcula proxy-core::layout y lo consumen tanto la vista previa como el PDF. La pantalla nunca recalcula posiciones. Lo que ves tiene que ser lo que se imprime. Una segunda geometría en TypeScript acabaría desviándose. Revisión; el plan lo trae compute_page_plan a src/stores/plan-store.ts
Las imágenes de los mazos viven en el almacén de blobs (app_data_dir/blobs/, direccionadas por SHA-256), no en la caché. La caché (app_cache_dir/renders/) solo guarda derivados y se puede borrar en cualquier momento. Los mazos dependen de sus imágenes; vaciar la caché nunca debe romper un mazo. BlobStore::gc (src-tauri/src/store/blobs.rs); test vaciar_la_cache_no_toca_las_imagenes_de_los_decks en src-tauri/src/commands/library.rs
La vista previa desmonta las imágenes de las páginas que no se ven (IntersectionObserver cuyo root es el viewport del ScrollArea compartido). Los mazos de cientos de cartas, si no, mantendrían cientos de imágenes decodificadas. Revisión; véase Vista previa en vivo
proxy-core es puro: sin Tauri, sin red, sin base de datos; tiene sus propios tipos de error y la app los convierte a AppError. Tests rápidos (cargo test -p proxy-core en segundos), reutilización y una frontera dura contra los efectos secundarios. src-tauri/crates/proxy-core/Cargo.toml simplemente no tiene esas dependencias
src-tauri/src/lib.rs solo arranca y registra comandos; cada área tiene su src-tauri/src/commands/<área>.rs. No debe crecer. El lib.rs de un proyecto hermano anterior llegó a unas 12 000 líneas. Revisión
Los errores son códigos, nunca prosa. Los comandos devuelven Result<T, AppError> ({ code, params }); la pantalla traduce con useLang().mensaje(e). Rust nunca escribe texto para el usuario. La interfaz puede estar en español o en inglés; un único sitio (src/lib/errores.ts) es dueño de la redacción. Sistema de tipos: AppError/ErrorCode en src-tauri/src/error.rs; véase IPC y manejo de errores
Los tipos compartidos vienen de Rust: #[derive(TS)] #[ts(export)], y luego pnpm bindings escribe src/bindings/. Nunca se editan esos ficheros a mano. Una única fuente de verdad para el formato de transmisión. .cargo/config.toml (TS_RS_EXPORT_DIR); Biome y Knip ignoran la carpeta
La pantalla habla con Rust solo a través de src/lib/ipc.ts (call() y on()), nunca con un invoke suelto. La interfaz Commands hace que el compilador compruebe los nombres y tipos de los comandos. Revisión; solo ipc.ts importa invoke
La vista web nunca sale a internet. Toda la red es de Rust. Todas las descargas externas pasan por fuentes::Externo (lista cerrada de host+ruta, redirecciones vueltas a comprobar, tope de tamaño, imágenes siempre decodificadas y recodificadas). Seguridad y privacidad; la CSP lo prohíbe de todos modos. Decodificar y recodificar elimina cualquier cosa hostil de los ficheros de terceros. CSP; src-tauri/src/fuentes/ (véase Fuentes externas)
Los diálogos de ficheros solo a través de src/lib/dialogos.ts (abrirParaImportar, guardarParaExportar). Recuerdan por separado las últimas carpetas de importar y de exportar (guardadas en Rust: commands/carpetas.rs, app_settings). Revisión
Los trabajos largos siguen un único patrón (abajo). La interfaz puede mostrar el progreso, cancelar y bloquear acciones que entren en conflicto. src-tauri/src/jobs.rs, src/lib/trabajos.ts
El encuadre de la imagen estira al hueco exacto (imaging::frame) y recorta solo si la proporción difiere en más de un 15 % (MAX_ESTIRADO). Es preferible una ligera deformación a perder ilustración o dejar huecos. Tests en imaging/frame.rs

El patrón de los trabajos largos

Un comando que tarda más de un instante devuelve de inmediato un job_id numérico y trabaja en spawn_blocking:

  1. let (job_id, cancelado) = state.jobs.alta(); (una bandera de cancelación, jobs.rs).
  2. Dentro de la tarea, let _marca = estado.trabajo(); marca la app como ocupada mientras viva la guarda (AppState::ocupado()).
  3. El progreso sale como eventos area://progress y el final como area://done, ambos con job_id; job_cancel/export_cancel voltea la bandera.
  4. El frontend usa lanzarTrabajo(progreso, final, lanzar, alProgreso) de src/lib/trabajos.ts, que se suscribe antes de lanzar (un trabajo corto puede terminar antes de que el invoke devuelva su id; los eventos tempranos se almacenan en un búfer por id).

Estar ocupado bloquea update_install y blob_gc con ErrorCode::Busy. Ejemplo: export_pdf en src-tauri/src/commands/export.rs.

Sistema de diseño (heredado del proyecto hermano)

El sistema de temas, las skins (Hera, Zeus), los materiales, src/themes.css (tweakcn), src/lib/themes.ts y src/components/theme-provider.tsx se han traído sin cambios, así que sus convenciones se aplican:

Regla Motivo
Radio 14 px, densidad 10 px (p-2.5, gap-2.5), texto base text-xs Coherencia visual; la app es densa por diseño
El scroll siempre usa el ScrollArea compartido (src/components/ui/scroll-area.tsx), nunca overflow-auto en código nuevo Barras de scroll coherentes y comportamiento de scroll virtual en WebKitGTK; el root del IntersectionObserver es su viewport
Las listas cuyos items tienen borde o anillo llevan padding p-1 Para que el contenedor de scroll no corte los anillos
Nada de title= nativo; usa <Tooltip> o WithTooltip (src/components/ui/with-tooltip.tsx) GTK dibuja los tooltips nativos con los colores del sistema. Lo hace cumplir pnpm check:title (scripts/sin-title-nativo.mjs)
WithTooltip debe ser el envoltorio exterior de otro *Trigger asChild de Radix Si no, se rompe la fusión de refs y manejadores de Slot y el desplegable o popover deja de abrirse (documentado al principio de with-tooltip.tsx)
Prueba el scroll y las animaciones en WebKitGTK, no en Chrome WebKitGTK es el webview real de Linux y se comporta de otra manera
El texto no se puede seleccionar (regla en src/index.css); solo los campos y los cuadros de copiar llevan select-text Es una app de escritorio, no un documento; WebKit se niega a seleccionar dentro de un input si algún ancestro tiene user-select: none, así que se restaura de forma explícita
Los modales son 100 % opacos con cualquier material o skin (regla al final de src/index.css) Con materiales de cristal o mate y la skin Hera, un diálogo dejaba ver el contenido de detrás y costaba leerlo; la regla del final de src/index.css fuerza el color de la tarjeta y quita el desenfoque de fondo de [data-slot="dialog-content"]
theme-provider.tsx es de solo lectura; solo acepta temas presentes en THEMES; los temas de tweakcn instalados se añaden al arrancar con src/lib/temas-instalados.ts Mantiene intacto el fichero heredado para poder resincronizarlo
Un diálogo con un proceso largo o un estado sin guardar propio lo declara con useEstadoParaCerrar({ enCurso, sinGuardar }) (src/lib/dialogos-abiertos.ts), con los mismos valores que pasa a DialogContent como ocupado / cambiosSinGuardar Cerrar toda la app pregunta por esos diálogos (Ajustes y shell). Es un hook por diálogo porque DialogContent no puede informarlo: los ficheros de src/components/ui/ son de solo lectura (heredados), así que no se editan
La ventana arranca oculta con fondo oscuro; se muestra cuando la página ha terminado de cargar (on_page_load en src-tauri/src/lib.rs) y main.tsx también llama a show() sobre la ventana Evita un destello blanco

Véanse Temas y skins y Arquitectura del frontend.

Internacionalización

  • Todo el texto de la interfaz pasa por t() con la cadena fuente en español como clave; cada traducción va en src/i18n/<código>/<área>.json de cada idioma de la interfaz (el inglés es la referencia; los términos siguen src/i18n/GLOSARIO.md). pnpm idiomas falla si falta alguna, si una clave está duplicada entre ficheros de área o si no cuadran los marcadores o las formas de plural. Las cuentas que se pasan a t() son números (sin toLocaleString/toFixed): t() las formatea y elige la forma del plural.
  • Los textos que viven en tablas de datos y se traducen en otro sitio se envuelven en paraTraducir("...") (función identidad) para que el comprobador los vea (por ejemplo, los textos de error de src/lib/errores.ts).
  • Las variables usan marcadores {name}: t("Descargando la {version}…", { version }).
  • El idioma de fábrica es el inglés (clave de localStorage proxybuilder.lang); t() recurre a la clave en español cuando no existe traducción, por eso importa la puerta.
  • El README está en inglés con una copia en español, README.es.md: cualquier cambio va a los dos, con las mismas secciones y capturas (capturas inglesas en docs/presentacion/en/, españolas en docs/presentacion/).

Detalles: Internacionalización.

Estilo de código

Aspecto Convención
Sangría Tabuladores en todo el TS/JS/JSON/CSS (Biome, .editorconfig); cuatro espacios en Rust (rustfmt). Motivo: los tabuladores son un byte por nivel; rustfmt reformatea ficheros enteros con su propio valor por defecto, así que pelearse con él haría que el editor y cargo fmt --check se contradijeran
Formato de Rust en los hooks rustfmt --edition 2021 sobre los ficheros en staged; nunca cargo fmt en lint-staged (véase Preparación del entorno de desarrollo)
Lints Clippy con -D warnings. Nada de unwrap()/expect() fuera de tests (#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] en los dos crates de Rust). Usa ?, ok_or_else o AppError
Permitir un lint Solo localmente con #[allow(clippy::...)] y un comentario que diga por qué; nunca --no-verify
Idioma del código Los comentarios, los comentarios de documentación y los mensajes de commit van en español. Los tipos de transmisión/dominio que cruzan la frontera usan nombres de campo en inglés en camelCase (#[serde(rename_all = "camelCase")]), p. ej. DeckCard, PagePlan, PrintSettings; las áreas más nuevas usan identificadores en español (actualizar, fuentes, diseno, biblioteca, Actualizador, ActualizacionInfo). Sigue al módulo que te rodea
Marcador de regla dura Los comentarios que enuncian una restricción innegociable empiezan con el emoji de señal de stop; búscalo con grep para encontrar las invariantes de un fichero
Nombres de comandos snake_case, <área>_<acción>: deck_save, card_search, update_check, design_save
Nombres de eventos <área>://progress y <área>://done: export://progress, identify://done, update://progress
Stores Zustand, uno por responsabilidad en src/stores/; selectores por campo (useUi((s) => s.actualizarSola))
Carpetas de features src/features/<área>/, con la lógica pura separada de los componentes en ficheros .ts para que Vitest pueda probarla sin renderizar
Alias de rutas del frontend @/ es src/
Código sin usar Knip falla ante ficheros/dependencias sin usar; src/bindings/ está exento
Valores por defecto que recordar Valores de impresión de fábrica: 2 mm de separación, sin sangrado, guías de corte apagadas, «rellenar las esquinas» apagado (las esquinas transparentes se imprimen del color del papel; corners::fill solo se ejecuta si se activa)

Dependencias y plataforma

  • Sin OpenSSL: reqwest usa rustls con default-features = false; CI falla si cargo tree -i openssl encuentra algo.
  • pnpm 12, no npm, con minimumReleaseAge: 1440 y allowBuilds en pnpm-workspace.yaml; regenera el lockfile con la versión de packageManager.
  • rusqlite se compila con SQLite bundled para que las tres plataformas se comporten igual. zip usa solo deflate (Rust puro). El OCR usa ocrs (Rust puro); los modelos se descargan en el primer uso, no se incluyen.
  • El contenido que no debe ir en los instaladores (marcos «Original» de Card Conjurer, packs descargables como Balatro, letras libres, letras de los temas de tweakcn) se descarga bajo demanda con un commit fijado y un SHA-256 por fichero y se guarda bajo app_data_dir. Véase Packs de marcos.

Cómo fluye un cambio típico

Añadir una función que necesita datos de Rust, de abajo arriba:

flowchart TD
    A["1. proxy-core: lógica pura + tests unitarios"] --> B["2. crate de la app: comando en commands/area.rs"]
    B --> C["3. registrarlo en generate_handler de lib.rs"]
    C --> D["4. derive TS en los tipos nuevos, pnpm bindings"]
    D --> E["5. entrada en Commands de src/lib/ipc.ts"]
    E --> F["6. acción del store (src/stores) o ayudante puro + vitest"]
    F --> G["7. componente en src/features/area, texto vía t()"]
    G --> H["8. traducciones en src/i18n/codigo/area.json"]
    H --> I["9. pnpm check + puertas de Rust"]
Loading
  1. Primero, la lógica pura. Si es determinista (análisis, geometría, estadísticas, imagen), ponla en src-tauri/crates/proxy-core/src/<módulo> con tests #[cfg(test)] (un dorado si es visual; véase Tests y CI). Dale su propio tipo de error.
  2. El comando. En src-tauri/src/commands/<área>.rs: #[tauri::command], recibe State<'_, AppState> (y AppHandle para los eventos), devuelve AppResult<T>. Convierte los errores del núcleo en AppError con un código y parámetros .with("detalle", ...). Para trabajo largo usa el patrón de trabajos de arriba.
  3. Regístralo en la lista tauri::generate_handler![...] de src-tauri/src/lib.rs. Olvidarlo compila, pero falla en tiempo de ejecución con un comando desconocido.
  4. Tipos: añade #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] a los tipos nuevos de petición/respuesta (usa #[ts(type = "number")] para los campos u64, como hace ActualizacionProgressEvent), y ejecuta pnpm bindings.
  5. src/lib/ipc.ts: importa el tipo generado y añade el comando a la interfaz Commands (args, result), y cualquier evento a Events.
  6. Estado: una acción de un store de Zustand que haga call() al comando, o un ayudante puro junto a la feature (que se pueda probar con tests). Los errores se guardan como AppError en bruto y se traducen en el borde con mensaje(e). Simula @/lib/ipc en el test del store, como hace src/stores/__tests__/actualizacion-store.test.ts.
  7. El componente en src/features/<área>/: primitivas compartidas de ui/, densidad y tamaño de texto de las reglas de diseño, ScrollArea para el scroll, WithTooltip para las ayudas, t("texto en español") para cada cadena.
  8. Traducciones en el src/i18n/<código>/<área>.json correspondiente de cada idioma de la interfaz (primero el inglés, que es la referencia).
  9. Puertas: pnpm check, pnpm check:rust-fmt, pnpm check:clippy, pnpm test:rust.

Ejemplo trabajado: el actualizador. src-tauri/src/actualizar.rs (lógica y envoltorio del plugin), luego src-tauri/src/commands/actualizar.rs (cuatro comandos, evento de progreso update://progress, guarda Busy), luego el registro en lib.rs, luego las entradas de src/lib/ipc.ts, luego src/stores/actualizacion-store.ts, luego features/shell/actualizacion-automatica.tsx y features/settings/actualizaciones.tsx, luego las cadenas en src/i18n/en/. Véase Versiones y actualizaciones automáticas.

Datos persistidos

  • Los cambios de esquema van en una nueva migración de SQLite numerada (en el código aparecen nombres como 0003, 0004); algunas columnas por mazo (fijado, notas, portada, idioma a mano) no tocan updated_at a propósito, para que las ediciones cosméticas no reordenen el orden por «modificado». Véase Almacenamiento y blobs.
  • Los campos nuevos en los tipos de mazo guardados son opcionales (se añaden como campos opcionales o claves opcionales, no como variantes nuevas de enum) para que las versiones antiguas de la app sigan abriendo mazos más nuevos. Ejemplo: CardSource::Custom { scryfallId?, externa? }. Véanse Modelo del mazo y Formato del paquete de mazo.
  • Las preferencias del usuario viven en ui-store (persistido de forma selectiva) o en app_settings de Rust (app_setting_get/app_setting_set); los anchos de los paneles usan el almacenamiento de react-resizable-panels.

Lista de comprobación de revisión

  • El trabajo pesado está en Rust; el frontend solo hace call().
  • Nada de unwrap/expect fuera de tests; los errores son códigos AppError.
  • Tipos de Rust nuevos exportados, pnpm bindings ejecutado de nuevo, ipc.ts actualizado.
  • Cada cadena en t(), entrada añadida en todos los idiomas, sin claves duplicadas entre áreas, pnpm idiomas limpio.
  • Sin title=, sin overflow-auto, sin invoke suelto, las listas con anillos llevan p-1.
  • Todo lo que bloquee por un trabajo largo comprueba ocupado() / gestiona Busy.
  • Un diálogo nuevo con un proceso largo o estado sin guardar llama a useEstadoParaCerrar; una API nueva de ventana de Tauri usada desde el WebView lleva su permiso en capabilities/default.json.
  • El uso de red pasa por Scryfall o fuentes::Externo.
  • Los dos README actualizados si cambió el comportamiento de cara al usuario.
  • Dejado sin commit y sin push cuando se trabaja como colaborador automático.

Véase también

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