Repository navigation
Arquitectura del frontend
English · Español
El frontend es una interfaz de una sola página en React 19 + TypeScript servida dentro del WebView de Tauri 2 (WebKitGTK en Linux). Solo pinta y guarda el estado de pantalla: el parseo, Scryfall, la geometría de página, el procesado de imagen, el PDF y las estadísticas viven todos en Rust (véase Arquitectura). Esta página describe cómo se monta la interfaz, los componentes compartidos, los helpers de src/lib, la configuración de las herramientas y las políticas de la UI (selección de texto, scroll, tooltips nativos, soluciones para WebKitGTK).
| Regla | Dónde se impone / implementa |
|---|---|
La UI habla con Rust solo mediante call() / on(), nunca con un invoke suelto. |
src/lib/ipc.ts (Commands, Events, call, on). |
Una sola geometría para pantalla y papel: las posiciones salen del PagePlan de Rust y la UI nunca las recalcula. |
src/stores/plan-store.ts (usePlan), véase Motor de maquetación. |
| La vista web no sale a internet; toda la red la hace Rust. | CSP de src-tauri/tauri.conf.json (connect-src: ipc: http://ipc.localhost); véase Fuentes externas. |
Todo el texto visible para el usuario pasa por t("<cadena fuente en español>"). |
src/components/language-provider.tsx, scripts/idiomas.mjs; véase Internacionalización. |
El scroll usa el ScrollArea compartido, nunca overflow-auto. |
src/components/ui/scroll-area.tsx. |
Nada de tooltips nativos con title=; se usa <Tooltip> / <WithTooltip>. |
scripts/sin-title-nativo.mjs (pnpm check:title) y la red de seguridad en ejecución src/components/tooltips-nativos.tsx. |
El texto no se puede seleccionar salvo dentro de los campos y los «cuadros de copiar» (select-text). |
src/index.css (@layer base). |
Los tipos compartidos vienen de Rust (#[derive(TS)]), se generan en src/bindings/ y no se editan a mano. |
pnpm bindings; véase IPC y manejo de errores. |
| Ruta | Responsabilidad |
|---|---|
src/main.tsx |
Arranque: árbol de proveedores, tema previo al montaje, mostrar la ventana. |
src/App.tsx |
Monta AppShell (cuatro vistas) y todos los diálogos globales; arranca los hooks de inicio. |
src/components/ |
Componentes compartidos que no son de una feature (proveedores, Confirmar, campos.tsx, LimiteError, TooltipsNativos, SelectorIdioma, Emoji/Bandera). |
src/components/ui/ |
Primitivas al estilo shadcn sobre radix-ui (véase la tabla de abajo). |
src/lib/ |
Helpers puros y la capa de IPC (ipc.ts, image-url.ts, deck-ops.ts, cartas.ts, trabajos.ts, dialogos.ts, imagen-viva.ts, errores.ts, idiomas.ts, por-idioma.ts, themes.ts, tema-inicial.ts, temas-instalados.ts, SmoothScroll.ts, utils.ts). |
src/stores/ |
Stores de Zustand, véase Stores de estado. |
src/features/<área>/ |
Pantallas por área: decklist, preview, export, variants, card-editor, library, stats, set-browser, shell, settings, disenar, tutorial (los recorridos guiados, véase Tutoriales), mesa (la mesa de juego, véase Mesa de juego). |
src/i18n/ |
Diccionarios de la interfaz, una carpeta por idioma (<código>/, un JSON por área, que funde el cargador), el traductor puro traducir.ts, el cargador perezoso cargar.ts, los tipos tipos.ts y el glosario de los traductores GLOSARIO.md. Véase Internacionalización. |
src/assets/emoji/ |
Las imágenes de Noto Color Emoji que usa la interfaz (las banderas del selector de idioma), con fuentes.json y LICENSE.md. Véase «Emoji y banderas» más abajo. |
src/bindings/ |
Tipos TS generados desde Rust (no se editan). |
src/index.css, src/themes.css, src/fonts.css, src/styles/
|
Entrada de Tailwind 4, paletas de temas, letras locales, skins y materiales, véase Temas y skins. |
src/hooks/use-foco-en-marcos.ts |
Devuelve el foco desde un visor <iframe>/<embed> al diálogo para que Esc siga funcionando. |
src/main.tsx hace, en orden:
- Solo en producción (
!import.meta.env.DEV), un listener decontextmenuen fase de burbujeo llama apreventDefault()salvo que el evento ya estédefaultPrevented(así los menús contextuales de Radix sobre las cartas siguen funcionando, pero nunca aparece el menú «Recargar / Inspeccionar» del WebView). -
prepararTemasInstalados()(src/lib/temas-instalados.ts): inyecta el CSS de los temas de tweakcn instalados y los añade aTHEMESantes de que React monte, porqueThemeProvidersolo acepta un tema guardado que esté enTHEMES. -
ponerTemaAntesDeMontar()(src/lib/tema-inicial.ts): escribedata-theme, la clasedarky los atributos de skin/fondo/material en<html>para que ya el primer pintado tenga los colores correctos (sin destello blanco). -
cargarDiccionario(leerIdioma())(src/i18n/cargar.ts) y, cuando termina (finally),montar()renderiza el árbol de proveedores (abajo). Solo el inglés va en el paquete principal; esperar al trozo del idioma elegido antes del primer render evita que la app salga un instante en inglés. Si no carga, se monta igual y los textos caen al inglés. - Mientras tanto (sin esperar al diccionario ni a un pintado)
ensenarVentana()llama agetCurrentWindow().show(). La ventana se crea oculta y con fondo oscuro entauri.conf.json(visible: false,backgroundColor), porque WebKitGTK no da fotogramas de animación (requestAnimationFrame) a una ventana oculta y esperar a un pintado hacía que la ventana apareciera unos 3 s tarde. Rust también la muestra enPageLoadEvent::Finishedy con un hilo de respaldo de 3 segundos ensrc-tauri/src/lib.rs; el plugin window-state está configurado para no guardar la visibilidad.
flowchart TD
A["React.StrictMode"] --> B["LimiteError (límite de errores)"]
B --> C["LanguageProvider"]
C --> D["ThemeProvider"]
D --> E["SmoothScrollClient"]
D --> F["TooltipProvider"]
F --> G["App"]
F --> H["Toaster (sonner)"]
F --> I["TooltipsNativos"]
G --> J["AppShell: cabecera + vista"]
G --> K["Diálogos globales: AddCard, Variant, Vincular, CardEditor, Stats, SetBrowser, ..."]
Por qué este orden:
-
LimiteErrorlo envuelve todo para que un error de render muestre una pantalla de recuperación en vez de una ventana en blanco. Como está fuera deLanguageProvider, no puede usaruseLang(); usatraduccionSuelta(el mismo diccionario, leeproxybuilder.langdirectamente). -
LanguageProviderestá fuera deThemeProviderporque el código detema-inicial/temas no necesita el idioma, pero cualquier diálogo necesita ambos. -
TooltipProviderengloba aApppara que todoTooltipcomparta un único retardo (RETARDO_TOOLTIPS, 700 ms, definido solo ensrc/components/ui/tooltip.tsx;skipDelayDurationse fuerza a 0 para que Radix nunca abra un segundo tooltip al instante).
App() llama a tres hooks y renderiza AppShell más los diálogos siempre montados:
-
useDeckInicial()(src/features/shell/use-deck-inicial.ts): arrancainiciarAutoguardado()einiciarPlan(), abre el último mazo (o crea uno), vuelve a renderizar las miniaturas de diseño obsoletas al abrir un mazo (rehacerMiniaturas) e instala el deshacer/rehacer globalCtrl/Cmd+Z/Ctrl/Cmd+Shift+Z(se ignora dentro de inputs, textareas ycontenteditable). -
useActualizacionAutomatica()yuseAvisosActualizacion(): el bucle de actualización automática y sus avisos (véase Ajustes y shell).
No hay router. AppShell (src/features/shell/app-shell.tsx) conmuta según useUi().vista ("deck" | "biblioteca" | "disenar" | "mesa", tipo Vista en src/stores/ui-store.ts):
vista |
Contenido |
|---|---|
deck |
Tres paneles redimensionables: DecklistPanel (izquierda, Panel de la lista del mazo), PreviewPane (centro, Vista previa en vivo), ExportPanel (derecha, Exportación). |
disenar |
DisenarScreen (Interfaz del diseñador). |
biblioteca |
LibraryScreen (Biblioteca de mazos). |
mesa |
MesaScreen (Mesa de juego): la entrada o la mesa. En esta vista la cabecera no enseña el nombre del deck, como en la Biblioteca. Salir de ella deja la partida abierta en Rust y la guarda. |
Los diálogos (AddCardDialog, VariantDialog, VincularDialog, VincularLoteDialog, CardEditorDialog, MoverADialog, FichasDialog, StatsDialog, SetBrowserDialog, ColeccionesDialog, RecursosDialog, LoteDialog, PngDialog) se montan una sola vez en App y los gobierna useUi().dialogo (una unión discriminada Dialogo con tipo), que se abre con useUi.getState().abrir({ tipo: ... }). SincronizarTemas también se monta aquí (carga los temas instalados desde Rust). El diálogo de Ajustes es estado local de AppShell.
Como los stores y los hooks de inicio viven en módulos independientes de App/AppShell, cambiar de vista no reinicia el mazo, el plan ni el autoguardado. La única excepción a «cambiar sale gratis» es la vista Diseñar: salir de ella con la carta sin guardar pregunta antes, porque DisenarScreen se desmonta y perdería su borrador (cambiarVista en AppShell, Ajustes y shell). AppShell monta además ConfirmarCierre, la guarda que pregunta antes de cerrar la ventana.
react-resizable-panels v4 está envuelto por src/components/ui/resizable.tsx (ResizablePanelGroup, ResizablePanel, ResizableHandle). Las disposiciones se persisten con useDefaultLayout({ id, storage: localStorage }):
| Id de la disposición (localStorage) | Propietario |
|---|---|
proxybuilder.paneles |
Vista de mazo de AppShell: paneles izquierda (por defecto 22, mín. 260 px), centro (mín. 360 px), derecha (por defecto 22, mín. 260 px). |
proxybuilder.panel-lista |
Dentro de DecklistPanel. |
proxybuilder.paneles.disenar |
DisenarScreen. |
src/index.css ensancha a 12 px la zona invisible de agarre de cada [data-slot="resizable-handle"] (la línea visible sigue siendo de 1 px) y mantiene siempre visibles los puntos del asa, para que los usuarios descubran que las columnas se pueden redimensionar.
Son componentes shadcn «new-york» (configuración en components.json, iconos de lucide, alias @/) adaptados al proyecto. Todos usan atributos data-slot; los skins y materiales de src/styles/ apuntan a esos slots.
| Componente | Notas |
|---|---|
button.tsx |
Variantes/tamaños con cva (xs, icon-xs, icon-sm...). Por defecto type="button", así que un botón dentro de un <form> nunca envía el formulario salvo que sea el botón de envío explícito. |
dialog.tsx |
Familia Dialog*. DialogContent implementa el protocolo de salida (véase abajo). DialogCuerpo es el cuerpo con scroll (usa ScrollArea). DialogFormulario, DialogCancelar, DialogClose. |
salida-dialogo.ts |
Contexto SalidaDelDialogo y useTrabajoEnElDialogo(activo, interrumpir): declara un trabajo en marcha dentro de un diálogo. |
formulario.tsx |
Formulario: el único <form action> permitido; cancela el form.reset() automático de React 19 tras una acción (haría que el Checkbox/Switch de Radix volvieran a su valor de montaje). Registra la acción en marcha en el diálogo y pasa un AbortSignal como 2.º argumento de la acción. |
scroll-area.tsx |
ScrollArea de Radix con arreglos para WebKitGTK (abajo). Props viewportClassName, viewportStyle, onViewportChange(el). |
tooltip.tsx, with-tooltip.tsx
|
Primitivas de tooltip y el helper WithTooltip. |
resizable.tsx |
Envoltorio del grupo de paneles. |
sonner.tsx |
Toaster; el tema sale de useTheme().resolvedMode (sin next-themes); cada toast tiene un botón de cierre interior. |
select.tsx, dropdown-menu.tsx, context-menu.tsx, popover.tsx, tabs.tsx, toggle.tsx, toggle-group.tsx, switch.tsx, slider.tsx, checkbox.tsx, input.tsx, progress.tsx, card.tsx, empty.tsx
|
Primitivas estándar. input.tsx activa el corrector ortográfico en los inputs de texto normales salvo que la clase contenga font-mono. |
grafico-barras.tsx, grafico-apilado.tsx
|
Gráficos SVG hechos a mano que usan las Estadísticas (sin librería de gráficos). |
Componentes compartidos que no son de ui:
| Componente | Propósito |
|---|---|
src/components/campos.tsx |
Controles compactos de panel con la densidad de la casa: Seccion (plegable, recuerda si está abierta en proxybuilder.seccion.<id>), Fila, CampoNumero (escritura libre, confirma al perder el foco/Enter, limita y redondea al paso, flechas del teclado, Shift = x10), FilaInterruptor, Selector, FilaDeslizador (onSoltar se dispara al confirmar, para repintados costosos). |
src/components/contenido-contextual.tsx |
ContenidoContextual: ContextMenuContent que ignora pointerup/click durante 250 ms tras abrirse, para que soltar el botón derecho que abrió el menú no active la opción que queda bajo el cursor. La hora de apertura la marca una referencia estable de callback cuando Radix monta el nodo del contenido, no al pintarse el envoltorio (que se pinta con el menú cerrado). Lo usan el menú de casilla, el de deck y los menús de la mesa de juego. |
src/components/confirmar.tsx |
Confirmar: diálogo corto de confirmación (variante destructiva, se cierra solo antes de llamar a onConfirmar). El opcional accionAlternativa: { texto, onClick } añade un tercer botón seguro a la derecha («Guardar y continuar»): es el que tiene el foco, variante por defecto, y la acción principal se queda en medio (contorno salvo que sea destructiva). onClick devuelve `Promise
|
src/components/limite-error.tsx |
Límite de errores como clase, con «Volver» / «Recargar la ventana». Avisa al usuario de que los mazos están a salvo en la biblioteca local. |
src/components/tooltips-nativos.tsx |
Red de seguridad en ejecución para el title nativo (abajo). |
src/components/smooth-scroll-client.tsx |
Monta SmoothScroll según el ajuste «Scroll suave». |
src/components/theme-provider.tsx, language-provider.tsx
|
Proveedores heredados; véanse Temas y skins e Internacionalización. |
src/components/selector-idioma.tsx |
SelectorIdioma: un Select de idiomas (bandera + nombre propio, cada elemento con su atributo lang), por defecto con todo IDIOMAS. Lo usan el idioma de la interfaz (Ajustes) y el de diseño (panel de exportar, vista previa de Diseñar). |
src/components/emoji.tsx |
Emoji, Bandera, urlEmoji, nombreNoto, banderaDe: emoji y banderas como imágenes de Noto (abajo). |
DialogContent (src/components/ui/dialog.tsx) decide si salir de un diálogo necesita confirmación. Las cuatro salidas (Esc, clic en el fondo, la X y los botones de salida DialogCancelar/DialogClose) pasan por una sola función, salida.pedirSalida. Pregunta «¿Interrumpir lo que está en marcha?» si:
- hay dentro una acción de
Formularioen marcha, o un trabajo declarado mediante la propocupado/useTrabajoEnElDialogo; si se responde que sí, se dispara elAbortSignalde la acción; o - el usuario ha escrito algo: cuentan los eventos
inputde confianza y los clics de confianza en checkbox/switch/radio dentro de este diálogo, salvo dentro de[data-filtro], deslizadores, rangos einput[type=search](buscar o mover no es «datos sin guardar»). PasacambiosSinGuardar={false}en los diálogos que siguen abiertos tras guardar o que no tienen nada que perder (p. ej.Confirmar, el diálogo de Ajustes).
Cerrar porque un trabajo ha terminado (la acción llama a onCerrar) es estado del padre, no una salida del usuario, así que nunca pregunta.
El protocolo de salida solo cubre cerrar un diálogo. Cerrar toda la app es otra guarda (ConfirmarCierre, Ajustes y shell) que necesita saber qué diálogos abiertos tienen un proceso en marcha o texto sin guardar. DialogContent no puede informarlo él mismo: los ficheros de src/components/ui/ son de solo lectura (heredados, no se editan), así que cada diálogo que lo necesita lo declara con useEstadoParaCerrar({ enCurso, sinGuardar }) de src/lib/dialogos-abiertos.ts, pasando los mismos valores que da a DialogContent como ocupado / cambiosSinGuardar. Convención para un diálogo nuevo con un proceso largo o estado propio sin guardar: pasar ambos a DialogContent y llamar al hook en el componente que lo pinta.
| Fichero | Exports y papel |
|---|---|
ipc.ts |
Interfaz Commands (nombre del comando a { args, result }, una entrada por cada #[tauri::command]), call<C>(), interfaz Events (nombre del evento a payload, p. ej. "resolve://progress", "export://done", "update://progress") y on(event, handler), que importa @tauri-apps/api/event de forma diferida y devuelve la función para dejar de escuchar. Añadir un comando de Rust significa añadir aquí su fila; entonces el compilador comprueba nombre y tipos en cada punto de llamada. |
errores.ts |
esAppError, mensajeDeError(e, t): texto en español por cada ErrorCode, traducido con t() e interpolado con AppError.params; MOTIVOS da un texto propio a cada params.motivo conocido de Rejected (mesa de juego) y params.code de Server. Véase IPC y manejo de errores. |
image-url.ts |
Constructores de URL para el protocolo pbimg:// que sirve src-tauri/src/protocol.rs: urlMiniatura (r/<blob>/<b64url(PreviewParams)>/<ancho>), urlBusqueda (t/), urlDiseno (d/), urlCapa (c/), urlArte (b/), urlExtra (x/), urlMesa (m/, imágenes de la mesa de juego; null para el reverso genérico, que pinta la pantalla), raizProtocolo() (base que usan las letras de los temas, f/). ANCHOS = [146, 244, 488, 672, 745, 1000]. La base sale de convertFileSrc("", "pbimg"), así que es correcta en cada plataforma (pbimg://localhost/ en Linux/macOS, http://pbimg.localhost/ en Windows). parametros() convierte PrintSettings + CardEdits en PreviewParams. |
deck-ops.ts |
Operaciones puras de lista sobre DeckCard[]: nuevoId, mover, moverGrupo, duplicar, quitar, insertar, actualizar, huecos (cartas en blanco para «Añadir página»). Sin React/Tauri, así que se prueban directamente con tests unitarios. |
cartas.ts |
Helpers de carta: imagenDorso, cartaPropia, nombreDeFichero (quita carpeta, extensión, numeración como 0001 - , marcas de copia como (1)/~2), MARCAS (etiquetas de DeckCard.marca), insignias(card) (insignias CUSTOM, ↩ EN, LOWRES, PLACEHOLDER, MISSING, PROXY/BORRADOR del diseño), listaParaReaplicar / aplicarReaplicado («Reaplicar idioma»). |
trabajos.ts |
lanzarTrabajo(progressEvent, doneEvent, launch, onProgress): lanza un trabajo largo de Rust y conecta sus eventos (véase abajo). |
cierre.ts |
pendientes(deckSinGuardar) / hayPendientes(p): qué se perdería o interrumpiría al cerrar la ventana (trabajo en marcha, actualización en curso, diálogo ocupado o sin guardar, carta sin guardar, mazo que no se pudo guardar, una partida abierta en la mesa). Lee los stores; lo usa ConfirmarCierre. Véase Ajustes y shell. |
dialogos-abiertos.ts |
Registro del estado propio de los diálogos abiertos para la guarda de cierre: registrarDialogo, estadoDeLosDialogos, hook useEstadoParaCerrar({ enCurso, sinGuardar }). |
dialogos.ts |
Diálogos de ficheros que recuerdan carpetas: abrirParaImportar, guardarParaExportar, elegirCarpetaParaExportar, recordar. Único punto de entrada para los diálogos de ficheros. Véase Ajustes y shell. |
imagen-viva.ts |
Helpers de vista previa en tiempo real: CargaUltima (carga una imagen cada vez, solo sobrevive la última pedida), useImagenViva, useRafaga (¿un valor cambia más rápido que cada 250 ms?), useAlRitmo (throttle con valor final). Véase Vista previa en vivo. |
themes.ts, tema-inicial.ts, temas-instalados.ts
|
Catálogo de temas y helpers de tema para el arranque; véase Temas y skins. |
SmoothScroll.ts |
Port a TypeScript de «SmoothScroll for websites» (anima el scrollTop nativo; no es Lenis). Puerta solo para escritorio: activo con cualquier user agent no móvil, porque la comprobación de Chrome/Safari original dejaba fuera algunos user agents de WebKitGTK. puedeMoverseY se exporta como función pura. |
idiomas.ts |
La tabla única de los 15 idiomas: LANGS, el tipo Lang, IDIOMAS (codigo, nativo, pais, locale, impresas), esLang, idioma(codigo) (con un código desconocido, el inglés), localeDe(lang) e IDIOMAS_SCRYFALL (los 11 con cartas impresas, el inglés primero y el portugués con su nombre corto). Los códigos son los de Scryfall (pt, zhs, zht) para que los idiomas de la interfaz, del diseño y del deck compartan claves; la etiqueta BCP-47 va en locale y es la que usan todas las llamadas a Intl y <html lang>. Véase Internacionalización. |
por-idioma.ts |
Leer y escribir el ByLang<T> de Rust más allá de es/en: porIdioma (cae a en, como ByLang::get), tieneIdioma, conIdioma, textosDe (sin respaldo inglés para los demás idiomas, como FaceDesign::textos) y sinTextoEn. Hace falta porque ts-rs no sabe aplanar el mapa genérico y el binding solo enseña es y en. Véase Motor del diseñador. |
utils.ts |
cn() (clsx + tailwind-merge). |
src/lib/trabajos.ts resuelve una condición de carrera: un trabajo corto de Rust puede emitir su evento final antes de que el invoke que lo lanza devuelva su jobId. Por eso el helper (1) se suscribe primero a los eventos de progreso y de fin, (2) lanza, (3) guarda en un búfer los eventos que llegan mientras jobId aún es desconocido, y luego los reproduce/filtra por jobId, y (4) devuelve { jobId, fin }, donde fin se resuelve con el payload de fin (también cuando lleva un error). Quienes lo llaman (resolver en src/features/decklist/acciones.ts, src/features/export/acciones.ts, png-dialog.tsx, lote-dialog.tsx, vincular-lote.tsx, completarIdioma en src/features/disenar/renumerar.ts, conProgreso en src/features/mesa/conexion.ts) reflejan el progreso en useTrabajo (véase Stores de estado) y cancelan con job_cancel o export_cancel.
La interfaz nunca depende de la letra de emoji del sistema para lo que pinta ella. src/components/emoji.tsx lleva cada emoji a una imagen de Noto Color Emoji de src/assets/emoji/ (import.meta.glob, fichero emoji_u<puntos de código> sin el selector de variante U+FE0F); <Emoji> la pinta a 1em, o el carácter tal cual si la app no trae esa imagen; <Bandera pais="ES"> compone la bandera con dos indicadores regionales. Por qué imágenes y no una letra: WebKit en macOS no pinta letras COLRv1, y la Segoe UI Emoji de Windows no tiene banderas, así que allí el selector de idioma enseñaría letras en vez de banderas.
Las imágenes las copia scripts/emoji-noto.mjs (las banderas de src/lib/idiomas.ts y los emoji que se le pasen; con --desde <clon>, sin red) de un commit fijo de googlefonts/noto-emoji: las banderas de third_party/region-flags/waved-svg/ (dominio público) y el resto de 2D/svg/ (Apache 2.0, LICENSE.md). fuentes.json guarda el commit y la ruta de origen y el SHA-256 de cada fichero. Un SVG de más de 64 KB (la bandera de España, por su escudo) se guarda como PNG de 128 px de ancho pasado por Inkscape: a 1em el detalle no se ve. Donde un símbolo es solo un icono, se usa lucide (la cuenta de cartas diseñadas de la lista de la biblioteca lleva un Pencil de lucide, no el carácter ✎).
La versión de Linux corre sobre WebKitGTK, que se comporta distinto de Chrome; el scroll hay que probarlo ahí. Soluciones concretas en el código:
-
ScrollAreanunca vuelve a renderizar su viewport cuando cambian los hijos. ElViewportde Radix renderiza un<style>hermano condangerouslySetInnerHTML={{ __html }}; React 19 compara la identidad del objeto, así que cada render reescribía la hoja de estilos, y en WebKitGTK eso fuerza un recálculo de estilos de todo el documento (~50 ms), lo que provocaba tirones al hacer scroll de contenido virtualizado.ScrollAreaconstruye el viewport una sola vez conuseMemoy pasa los hijos reales a través de un contexto (HijosDelViewport/ContenidoDelViewport);stylese compara por valor para mantener estable el memo. -
ScrollBarusaforceMount: Radix desmontaba la barra 600 ms después de dejar de hacer scroll y la volvía a montar con el siguiente movimiento; ese cambio de estado volvía a renderizar el viewport y reescribía otra vez el<style>(un fotograma de ~120 ms en el primer tick de la rueda tras una pausa). Las barras ocultas usandata-[state=hidden]:pointer-events-none opacity-0. - El viewport tiene
relativepara que los inputs ocultos con posición absoluta (los inputs de formulario delCheckboxde Radix) se midan contra el elemento que hace scroll y no contra la raíz, que no recorta (si no, los cuerpos de los diálogos ganaban una enorme zona de scroll vacía). -
Las alturas máximas van en el viewport (
viewportClassName/viewportStyle); unmax-heighten la raíz no hace nada porque la raíz no recorta. - Los tooltips nativos los dibuja GTK con el tema del sistema y se ven ajenos; de ahí la prohibición de
title. - A diferencia de Chrome, WebKit no deja seleccionar texto dentro de un input cuando un ancestro tiene
user-select: none;src/index.cssvuelve a activar la selección eninput,textareay[contenteditable]. - La escala de la UI usa el
font-sizede la raíz (rem), nunca elzoomde CSS:zoomrompeposition: fixedy el posicionamiento porgetBoundingClientRectde Radix en WKWebView (véase Temas y skins). - En una pantalla sin GPU (CI/nube), las animaciones de entrada de los diálogos no avanzan y los dejan invisibles pero bloqueando los clics; ejecuta la app bajo Xvfb con
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1(no hace falta en un escritorio real).
Usa siempre el ScrollArea compartido (DialogCuerpo para los cuerpos de diálogo). Los únicos overflow-auto que quedan en src/ son el <pre> del límite de errores y el contenido de los menús/select de Radix (que tienen sus propias reglas de max-h). Las listas cuyos items tienen borde o anillo de foco llevan padding p-1 para que los anillos no se corten en el borde del scroll. ScrollArea.onViewportChange(el) entrega el elemento que realmente hace scroll; el panel de vista previa, el diálogo de añadir carta, el explorador de sets, la biblioteca y otros lo ponen en el contexto RaizScroll, y useVisible(el) (src/features/preview/visibilidad.tsx) lo usa como root de un IntersectionObserver con rootMargin: "100% 0px" (una pantalla por encima y otra por debajo). Las páginas fuera de pantalla conservan sus cajas pero desmontan su <img>, de modo que WebKit libera los bitmaps y Rust solo procesa lo que se ve. La raíz debe ser el viewport, no el documento, porque el propio documento nunca hace scroll (véase Vista previa en vivo).
src/index.css: body { user-select: none } (sensación de app de escritorio); input, textarea, [contenteditable] llevan user-select: text; los «cuadros de copiar» de solo lectura añaden la clase de Tailwind select-text (p. ej. el mensaje del límite de errores). Los elementos interactivos (button, [role=button], a[href], select, summary, label[for]) llevan cursor: pointer porque el preflight de Tailwind pone cursor: default en los botones.
- Estática:
scripts/sin-title-nativo.mjsescanea todo.tsxy falla si una etiqueta HTML en minúscula, o uno de los componentes de paso (Table*,Badge,Label,Button), lleva un atributotitle=. Props comotitulo=en nuestros propios componentes están bien. Se ejecuta enpnpm checky en el hook de pre-commit. - En ejecución:
TooltipsNativos(montado enmain.tsx) usa unMutationObserversobredocument.bodypara mover cualquiertitleadata-titulo-nativo(y aaria-labelsi el elemento no tiene nombre accesible), y luego lo muestra con elTooltipde la casa anclado a unspanfijo e invisible colocado sobre el elemento señalado, con el mismo retardo.iframe/frameestán exentos (sutitlees el nombre accesible). En desarrollo registra un aviso en la consola por cada conversión.
Tooltip también corrige una rareza de Radix: con un tooltip abierto sobre un modal, el primer Esc cerraba solo el tooltip. TooltipContent reenvía el Escape a la capa inferior cuando el tooltip se ha desmontado de verdad (reenviarEscape, solo si existe un diálogo/menú/listbox).
WithTooltip renderiza él mismo <TooltipTrigger asChild>. Al combinarlo con otro trigger de Radix (DropdownMenuTrigger, PopoverTrigger, DialogTrigger...), el tooltip debe ser el envoltorio exterior (<WithTooltip><DropdownMenuTrigger asChild><Button/></DropdownMenuTrigger></WithTooltip>); al revés se rompe la fusión de refs/handlers y el menú deja de abrirse. Un content vacío o nulo renderiza el hijo sin tocar.
Radio de 14 px (--radius forzado en src/index.css), densidad de 10 px (p-2.5 / gap-2.5), texto base text-xs, títulos de sección text-[11px] uppercase. Los detalles y el sistema de temas/skins están en Temas y skins; la lista completa de convenciones está en Convenciones. El feedback es toast de sonner (errores: toast.error(mensaje(e))).
| Fichero | Contenido |
|---|---|
package.json |
Scripts (dev, build = tsc && vite build, check, check:tipos, check:biome, check:title, idiomas, check:knip, test, bindings, check:clippy, check:rust-fmt, test:rust, release). pnpm@12 fijado en packageManager, engines.node >= 22. React 19, Vite 7, Vitest 5, Tailwind 4 (@tailwindcss/vite), zustand 5, react-resizable-panels 4, @dnd-kit/core, sonner, radix-ui, lucide-react. |
vite.config.ts |
Plugins react() + tailwindcss(); alias @ a src; puerto fijo 1420 con strictPort; ignora src-tauri/** en el watcher (Cargo lo recompila); clearScreen: false para que los errores de Rust sigan visibles; TAURI_DEV_HOST opcional para HMR a través de un host de la LAN. |
vitest.config.ts |
environment: "node" (sin DOM: los tests simulan localStorage ellos mismos), restoreMocks, incluye src/**/*.test.ts(x) y scripts/**/*.test.mjs. |
tsconfig.json |
strict, noUnusedLocals/Parameters, noFallthroughCasesInSwitch, jsx: react-jsx, alias de rutas @/*, include: ["src"]. tsconfig.node.json cubre vite.config.ts. |
biome.json |
Formateador: tabuladores, ancho de línea 100, comillas dobles, comas finales. Linter recomendado con muchas reglas de a11y desactivadas (es una app de escritorio con controles propios) y useExhaustiveDependencies desactivada. Ignora src/bindings, src/index.css, src/themes.css, src/fonts.css, src/styles, plans, tmp, dist. Formateador de CSS desactivado. |
knip.json |
Comprobación de código muerto (pnpm check:knip): ficheros/dependencias/binarios sin usar; reglas de exports/types desactivadas; ignora src/bindings/**. |
components.json |
Ajustes del CLI de shadcn (estilo new-york, cssVariables: true, alias). |
pnpm-workspace.yaml |
minimumReleaseAge: 1440 (solo versiones de hace más de un día) y allowBuilds: esbuild. Regenera el lockfile con la versión de pnpm fijada; de lo contrario, otro pnpm podría resolver versiones recién publicadas que luego la instalación del mantenedor rechaza. |
lint-staged.config.mjs + .husky/pre-commit
|
Al hacer commit: biome check --write sobre TS, biome format --write sobre JSON/HTML, rustfmt --edition 2021 a pelo sobre los .rs preparados (nunca cargo fmt, que reformatearía ficheros que no se han tocado); luego tsc --noEmit en todo el proyecto, pnpm idiomas, pnpm check:title y pnpm check:clippy si cambió Rust. |
Véase Tests y CI para saber cómo se ejecutan en CI, y Preparación del entorno de desarrollo para la puesta en marcha inicial.
src/lib/__tests__/: cartas.test.ts, deck-ops.test.ts, dialogos.test.ts, errores.test.ts, idiomas.test.ts (los 15 idiomas en el orden de LANGS, locales BCP-47 canónicos, los 11 de Scryfall, un código desconocido cae al inglés), imagen-viva.test.ts, image-url.test.ts, tema-inicial.test.ts, temas-instalados.test.ts, trabajos.test.ts. src/i18n/__tests__/traducir.test.ts cubre la cadena de respaldo, los plurales y el formato de los números; src/components/__tests__/emoji.test.tsx comprueba que la bandera de cada idioma tiene imagen (pintada con react-dom/server, sin DOM). Los tests de los stores se listan en Stores de estado. No hay tests de componentes con DOM: el entorno de Vitest es node.
- Comando nuevo de Rust: añade la fila a
Commandsensrc/lib/ipc.ts, exporta los tipos con#[derive(TS)] #[ts(export)]y ejecutapnpm bindings. - Evento nuevo de Rust: añádelo a
Eventsenipc.ts; para un trabajo largo usalanzarTrabajo. - Diálogo nuevo: constrúyelo con
Dialog*(DialogCuerpopara el scroll,DialogFormulariopara formularios), añade una variante deDialogoenui-store.tsy móntalo enApp.tsx. - Texto nuevo:
t("Cadena fuente en español")más una entrada en el diccionario de cada idioma, véase Internacionalización. - Emoji nuevo en la interfaz:
node scripts/emoji-noto.mjs <emoji>y pintarlo con<Emoji>. - Preferencia persistida nueva: véase Stores de estado para las claves que ya se usan.
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