Skip to content

Arquitectura del frontend

Claude edited this page Oct 26, 2026 · 7 revisions

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).

Límites y reglas innegociables

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.

Mapa del código

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.

Secuencia de arranque

src/main.tsx hace, en orden:

  1. Solo en producción (!import.meta.env.DEV), un listener de contextmenu en fase de burbujeo llama a preventDefault() 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).
  2. prepararTemasInstalados() (src/lib/temas-instalados.ts): inyecta el CSS de los temas de tweakcn instalados y los añade a THEMES antes de que React monte, porque ThemeProvider solo acepta un tema guardado que esté en THEMES.
  3. ponerTemaAntesDeMontar() (src/lib/tema-inicial.ts): escribe data-theme, la clase dark y los atributos de skin/fondo/material en <html> para que ya el primer pintado tenga los colores correctos (sin destello blanco).
  4. 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.
  5. Mientras tanto (sin esperar al diccionario ni a un pintado) ensenarVentana() llama a getCurrentWindow().show(). La ventana se crea oculta y con fondo oscuro en tauri.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 en PageLoadEvent::Finished y con un hilo de respaldo de 3 segundos en src-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, ..."]
Loading

Por qué este orden:

  • LimiteError lo envuelve todo para que un error de render muestre una pantalla de recuperación en vez de una ventana en blanco. Como está fuera de LanguageProvider, no puede usar useLang(); usa traduccionSuelta (el mismo diccionario, lee proxybuilder.lang directamente).
  • LanguageProvider está fuera de ThemeProvider porque el código de tema-inicial/temas no necesita el idioma, pero cualquier diálogo necesita ambos.
  • TooltipProvider engloba a App para que todo Tooltip comparta un único retardo (RETARDO_TOOLTIPS, 700 ms, definido solo en src/components/ui/tooltip.tsx; skipDelayDuration se fuerza a 0 para que Radix nunca abra un segundo tooltip al instante).

App.tsx: vistas y diálogos globales

App() llama a tres hooks y renderiza AppShell más los diálogos siempre montados:

  • useDeckInicial() (src/features/shell/use-deck-inicial.ts): arranca iniciarAutoguardado() e iniciarPlan(), 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 global Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z (se ignora dentro de inputs, textareas y contenteditable).
  • useActualizacionAutomatica() y useAvisosActualizacion(): 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.

Disposición de los paneles

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.

Componentes compartidos (src/components/ui)

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).

El protocolo de salida de los diálogos

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 Formulario en marcha, o un trabajo declarado mediante la prop ocupado / useTrabajoEnElDialogo; si se responde que sí, se dispara el AbortSignal de la acción; o
  • el usuario ha escrito algo: cuentan los eventos input de confianza y los clics de confianza en checkbox/switch/radio dentro de este diálogo, salvo dentro de [data-filtro], deslizadores, rangos e input[type=search] (buscar o mover no es «datos sin guardar»). Pasa cambiosSinGuardar={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.

Referencia de src/lib

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).

Trabajos largos: lanzarTrabajo

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.

Emoji y banderas

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 ✎).

WebKitGTK y consideraciones de plataforma

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:

  • ScrollArea nunca vuelve a renderizar su viewport cuando cambian los hijos. El Viewport de Radix renderiza un <style> hermano con dangerouslySetInnerHTML={{ __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. ScrollArea construye el viewport una sola vez con useMemo y pasa los hijos reales a través de un contexto (HijosDelViewport / ContenidoDelViewport); style se compara por valor para mantener estable el memo.
  • ScrollBar usa forceMount: 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 usan data-[state=hidden]:pointer-events-none opacity-0.
  • El viewport tiene relative para que los inputs ocultos con posición absoluta (los inputs de formulario del Checkbox de 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); un max-height en 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.css vuelve a activar la selección en input, textarea y [contenteditable].
  • La escala de la UI usa el font-size de la raíz (rem), nunca el zoom de CSS: zoom rompe position: fixed y el posicionamiento por getBoundingClientRect de 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).

Política de scroll

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).

Política de selección de texto

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.

Prohibición del title nativo (dos capas)

  1. Estática: scripts/sin-title-nativo.mjs escanea todo .tsx y falla si una etiqueta HTML en minúscula, o uno de los componentes de paso (Table*, Badge, Label, Button), lleva un atributo title=. Props como titulo= en nuestros propios componentes están bien. Se ejecuta en pnpm check y en el hook de pre-commit.
  2. En ejecución: TooltipsNativos (montado en main.tsx) usa un MutationObserver sobre document.body para mover cualquier title a data-titulo-nativo (y a aria-label si el elemento no tiene nombre accesible), y luego lo muestra con el Tooltip de la casa anclado a un span fijo e invisible colocado sobre el elemento señalado, con el mismo retardo. iframe/frame están exentos (su title es 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).

Regla de composición de WithTooltip

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.

Convenciones de diseño

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))).

Configuración de las herramientas

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.

Tests que protegen los cimientos del frontend

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.

Por dónde empezar para ampliarlo

  • Comando nuevo de Rust: añade la fila a Commands en src/lib/ipc.ts, exporta los tipos con #[derive(TS)] #[ts(export)] y ejecuta pnpm bindings.
  • Evento nuevo de Rust: añádelo a Events en ipc.ts; para un trabajo largo usa lanzarTrabajo.
  • Diálogo nuevo: constrúyelo con Dialog* (DialogCuerpo para el scroll, DialogFormulario para formularios), añade una variante de Dialogo en ui-store.ts y móntalo en App.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.

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