Skip to content

Convenciones

Pablo Martínez López edited this page Nov 10, 2026 · 8 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)
Iconos con <Icono icon="prefijo:nombre"> (src/components/icono.tsx), nunca lucide-react fuera de src/components/ui/; tras añadir un id, pnpm iconos Un solo componente para los iconos de Iconify propios de la app (incluidos, sin red) y los que elige la persona; pnpm check:iconos falla si src/lib/iconos/incluidos.ts no está al día. Véase Iconos
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
Un botón desactivado dice por qué en su tooltip (WithTooltip alrededor de un span, porque un botón desactivado no recibe eventos del puntero): «Elige un deck.», «El deck está vacío.»… Un botón gris sin motivo parece roto
Las acciones destructivas o irreversibles confirman con Confirmar (destructiva): borrar un preset o el índice de nombres, expulsar a alguien de una partida, dejar el servicio de amigos. Los cambios fáciles de deshacer no preguntan, ofrecen «Deshacer» en el aviso (quitar cartas, «Auto ordenar») Un clic perdido no debe costar trabajo, pero las ediciones de siempre no deben frenarse con diálogos
Las acciones lentas enseñan que están ocupadas: el botón se desactiva con el icono girando mientras dura (useOcupado en src/lib/use-ocupado.ts, o estado local), y una carga que puede fallar enseña su error con «Reintentar» en su sitio (EstadoCarga) Sin envíos dobles, y un fallo nunca es un callejón sin salida que obliga a cerrar y abrir el diálogo
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.

Móvil y táctil

Una sola interfaz que cambia de disposición por formato; nunca hay «pantallas de móvil» aparte y el escritorio no cambia. La referencia es Móvil; estas son las reglas que debe cumplir un cambio:

  • El formato sale de useFormato() (src/lib/formato.ts), y «¿es Android o iOS?» de src/lib/plataforma.ts. Nunca se adivina por el user agent.
  • Nada funciona solo con el ratón. Todo lo de pasar el ratón por encima o el clic derecho necesita su camino con el dedo (pulsación larga, un toque o un botón «⋯») y lo dice en un comentario // táctil: …; pnpm check:tactil falla si no. Un gesto por elemento: un elemento se arrastra o abre un menú con la pulsación larga, nunca las dos cosas.
  • Los objetivos táctiles miden al menos 2,5 rem (unos 43 px con el texto táctil al 108 %) y la disposición respeta las zonas seguras y el teclado (dvh, visualViewport).
  • Los diálogos son DialogoAdaptable (a pantalla completa en móvil), los paneles son Hoja, y un diálogo con secciones usa ListaSecciones. El botón «atrás» de Android cierra primero la capa de Radix de arriba; un diálogo con secciones vuelve con onEscapeKeyDown.
  • Los tooltips con información de verdad usan ConAyuda o AyudaAlTocar; un botón con solo un icono lleva siempre aria-label.
  • Los ficheros que elige la persona pasan por src-tauri/src/fichero.rs (en Android son URIs content://); lo que solo hace Android se llama desde Rust por src-tauri/src/android.rs.
  • El TLS se configura solo en src-tauri/src/tls.rs.
  • Los cambios visuales se comprueban en el emulador, en tamaño de móvil y de tableta (Preparación del entorno de desarrollo).

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, sin lucide-react fuera de components/ui/ (pnpm iconos tras un id de icono nuevo), 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

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally