Saltar al contenido

Theming

Esta página está traducida del inglés. Leer la página en inglés

Estado: implementado en crates/zeyn/assets/zeyn.css.

El UI kit predeterminado lee cada color, fuente, tamaño y radio de propiedades personalizadas de CSS en :root. Una organización cambia la apariencia sobrescribiendo unas pocas. Nunca necesita editar zeyn.css ni escribir selectores.

1. Cómo se aplica un tema

El servidor sirve primero zeyn.css y después, cuando la app tiene una, una hoja de estilos adicional con las sobrescrituras de tokens. El archivo de sobrescrituras solo contiene bloques :root:

:root {
  --z-brand: #7a2448;
  --z-font: "Source Sans 3", "Segoe UI", system-ui, sans-serif;
  --z-radius: 8px;
  --z-density: 0.75;
}

Reglas para el archivo de sobrescrituras:

  • Debe servirse desde el propio origen de la app. La Content Security Policy es style-src 'self', por lo que los bloques <style> en línea y las hojas de estilos de terceros se bloquean.
  • Las fuentes deben estar ya instaladas en las máquinas de los usuarios o servirse desde el origen de la app. El kit en sí no carga fuentes web.
  • Cárgalo después de zeyn.css, para que a igualdad de selectores gane el orden.
  • Define los tokens base de la sección 2. Los tokens derivados de la sección 3 los siguen automáticamente. Sobrescribe un token derivado solo cuando el valor calculado no funcione para tu marca.

Temas claro, oscuro y de la app

Toda app tiene un selector de tema en el menú, junto al selector de idioma. Ofrece System (sigue prefers-color-scheme), Light, Dark y cualquier tema que defina la app:

App::new("Billing")
    .theme(Theme::new().brand("#7a2448").radius(6))
    .themes([
        Theme::light(),
        Theme::dark(),
        Theme::named("sand", [("--z-bg", "#f5f1ea"), ("--z-surface", "#fffdf8")]).label(t!("theme.sand")),
    ])
    .default_theme("system")
  • App::theme es la apariencia de la organización y se aplica bajo todos los temas.
  • App::themes lista lo que los usuarios pueden elegir. Sin ella, la lista es Light y Dark. System se ofrece cuando ambos están en la lista. Theme::named(...) se construye sobre el tema claro; .dark_base() lo construye sobre el oscuro.
  • App::default_theme es el tema de los usuarios que no han elegido ninguno. system si no se define.
  • App::theme_picker(false) oculta el selector; todos obtienen el tema predeterminado.
  • Un solo tema bloquea la app: .themes([Theme::named("acme", tokens)]) o .theme(Theme::dark()).

Los tokens se comprueban cuando arranca la app. App::check (y por tanto router y serve) informa de nombres de token desconocidos, valores que podrían salirse de su declaración (;, llaves, \, <, >, url(), nombres de tema incorrectos, un tema predeterminado desconocido y cada par de colores de la sección 5 que un tema deje por debajo de WCAG 2.2 AA. Solo los valores #rgb y #rrggbb se pueden comprobar en cuanto al contraste; las demás sintaxis de color se dan por buenas.

Todos los temas se sirven en una sola hoja de estilos, /_zeyn/theme.css, construida una vez al arrancar y cargada después de las hojas de estilos del kit. La elección se guarda en la cookie zeyn_theme y, para un usuario con sesión iniciada en una app con un view store (App::datagrid_views), en ese store, de modo que lo sigue a un dispositivo nuevo. El servidor renderiza el tema en <html>, así que el primer pintado ya es correcto y no hace falta ningún script en línea:

MarcadoResultado
<html>System: sigue prefers-color-scheme
<html data-theme="light">Siempre claro
<html data-theme="dark">Siempre oscuro
<html data-theme="light" data-zeyn-theme="sand">El tema de la app sand, sobre el tema claro

Cambiar el tema en el selector solo cambia estos atributos y el meta color-scheme, y luego llama a POST /_zeyn/theme. No se recarga nada.

Para dar al tema oscuro sus propios valores en un archivo de sobrescrituras escrito a mano, repite los selectores que usa el kit:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --z-bg: #0e1012; }
}
:root[data-theme="dark"] { --z-bg: #0e1012; }

Densidad

--z-density escala la unidad de espaciado. 1 es normal, 0.75 es compacto. <html data-density="compact"> establece 0.75 sin hoja de estilos. Los controles y las filas del grid nunca son más pequeños que 24 px, así que el modo compacto sigue cumpliendo el tamaño de destino de WCAG 2.2.

La altura de fila de un grid virtual la fija el servidor (Paging::virtual_rows(px), 32 px de forma predeterminada) porque el runtime calcula a partir de ella las posiciones de desplazamiento. La densidad no la cambia.

2. Tokens base

Estos son los tokens que se espera que establezca una organización.

TokenPropósitoClaroOscuro
--z-brandEl único color de marca. Botones primarios, la banda del nombre de la app, el elemento de menú actual, la fila con foco, el anillo de foco#0f6674mismo valor; consulta --z-accent
--z-on-brandTexto sobre una superficie --z-brand sólida#ffffff#ffffff
--z-fontPila de fuentes de la interfaz"Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, "Noto Sans", sans-serifigual
--z-font-monoCódigo, ejemplos de APIui-monospace, "Cascadia Mono", "SF Mono", "Segoe UI Mono", Menlo, Consolas, monospaceigual
--z-font-sizeTexto del cuerpo0.875rem (14 px)igual
--z-font-size-smCeldas del grid, etiquetas, filtros0.8125rem (13 px)igual
--z-font-size-xsEncabezados de columna, títulos de grupo de menú, información de página0.75rem (12 px)igual
--z-radiusRadio de esquina base, de 0 a 12 px. Todos los componentes escalan a partir de él4pxigual
--z-densityMultiplicador de espaciado, 1 normal, 0.75 compacto1igual
--z-bgFondo de página detrás de los paneles#f4f5f6#121416
--z-surfacePaneles, grids, ventanas, entradas#ffffff#1a1d20
--z-surface-2Encabezado del grid, filas alternas, pie de ventana, entradas de solo lectura#f7f8f9#202326
--z-hoverFondo al pasar el cursor para filas, elementos de menú, botones#eceef1#2a2e33
--z-borderDivisores y contornos de panel#dcdfe3#33383e
--z-border-strongContornos de entradas y botones, línea del encabezado. Debe alcanzar 3:1 frente a --z-surface#7d848d#7a818a
--z-textTexto del cuerpo y valores#1a1d21#e4e6e9
--z-text-mutedEtiquetas, encabezados, texto secundario. Debe alcanzar 4.5:1 frente a --z-surface-2#5b626b#a2a9b1
--z-dangerErrores, marcas de obligatorio, filas de peligro#b3261e#f28b82
--z-warningFilas de advertencia#8a5300#e6b45a
--z-successFilas de éxito#1e6b3a#6fcf8f
--z-infoToasts informativos#2b5d8a#8ab8e6

3. Tokens derivados

Se calculan a partir de los tokens base con color-mix() y la sintaxis de color relativo. Se actualizan solos cuando cambia un token base.

TokenPropósitoClaroOscuro
--z-accentColor de marca usado como texto o línea fina sobre una superficie: enlaces, elemento de menú actual, barra de la fila con focovar(--z-brand)oklch(from var(--z-brand) 0.8 c h), el matiz de la marca con un tono claro fijo. Los navegadores sin sintaxis de color relativo reciben color-mix(in oklab, var(--z-brand) 45%, #fff)
--z-primaryRelleno del botón primariovar(--z-brand)var(--z-accent)
--z-on-primaryTexto del botón primariovar(--z-on-brand)#121416
--z-primary-hoverBotón primario al pasar el cursormarca al 86% mezclada con negroacento al 84% mezclado con blanco
--z-primary-activeBotón primario pulsadomarca al 74% mezclada con negroacento al 70% mezclado con blanco
--z-brand-subtleTinte detrás del elemento de menú actual y de la fila del grid con focoacento a --z-tint sobre --z-surfacemisma fórmula
--z-focusAnillo de focovar(--z-accent)var(--z-accent)
--z-danger-subtle, --z-warning-subtle, --z-success-subtle, --z-info-subtleFondos de filas y mensajescolor de estado a --z-tint sobre --z-surfacemisma fórmula
--z-tintIntensidad de los tintes sutiles9%20%
--z-spaceUnidad de espaciadocalc(4px * var(--z-density))igual
--z-controlAltura de botones y entradasmax(24px, 8 * --z-space) (32 px)igual
--z-rowAltura de fila del grid para grids paginados e infinitosmax(24px, 8 * --z-space) (32 px)igual
--z-gapEspacio entre secciones de una pantalla3 * --z-space (12 px)igual
--z-radius-sm, --z-radius-lgPartes pequeñas (filtros, código), ventanas0.75x y 1.5x de --z-radiusigual
--z-shadow-2Solo ventanas y toasts. Las filas del grid nunca llevan sombrasombra suave de dos capasmás oscura
--z-backdropDetrás de una ventana modalrgb(20 24 28 / 40%)rgb(0 0 0 / 60%)
--z-dirSentido de lectura como signo, para desplazamientos sin forma lógica (sombras interiores que marcan el lado de inicio o de fin, transformaciones de desplazamiento): calc(2px * var(--z-dir))1, y -1 con dir="rtl"igual

Tokens por elemento establecidos por el marcado del servidor: --z-width (ancho de ventana), --z-flex (proporción de columna), --z-row-height (altura de fila del grid virtual) y, para la biblioteca de componentes, --z-stack-gap, --z-split, --z-progress, --z-columns, --z-level y --z-pop-x / --z-pop-y (posición de popup).

Los colores de serie de los gráficos son --z-chart-1 a --z-chart-6 en components.css, con valores más claros en el tema oscuro. Cada uno alcanza 3:1 frente a --z-surface. Sobrescríbelos como cualquier token base.

4. Elegir un color de marca

  • --z-brand lleva texto blanco en el tema claro (botones, banda del nombre de la app). Elige un color con al menos 4.5:1 frente a --z-on-brand. La mayoría de los colores de marca de tono medio a oscuro lo cumplen. Para un color de marca claro, como el amarillo, establece también --z-on-brand con un color oscuro.
  • En el tema oscuro el kit aclara la marca para el texto, los anillos y los botones primarios, y mantiene la marca sólida para la banda del nombre de la app. Una marca distinta se ve intencionada en ambos temas sin un segundo color.
  • Mantén los colores de estado distintos de la marca. Una marca roja junto a filas de error rojas dificulta detectar los errores; en ese caso desplaza --z-danger hacia un rojo diferente o mantén la marca fuera de los grids sobrescribiendo --z-brand-subtle.
  • Comprueba tus colores usando como objetivo la tabla de la sección 5. Cualquier par por debajo del umbral incumple WCAG 2.2 AA.

5. Contraste verificado

Medido en Chromium sobre la app de ejemplo en ejecución: cada token se resolvió a su valor sRGB renderizado (incluidos color-mix() y los colores relativos), y después se calculó la razón de contraste de WCAG 2.x. Umbrales: 4.5:1 para texto, 3:1 para los contornos de componentes de interfaz y los indicadores de foco.

ParClaroOscuroNecesario
--z-text sobre --z-bg15.4914.774.5
--z-text sobre --z-surface16.9113.544.5
--z-text sobre --z-surface-215.9112.634.5
--z-text sobre --z-hover14.5510.934.5
--z-text sobre --z-brand-subtle (fila con foco)14.799.274.5
--z-text sobre --z-danger-subtle14.739.764.5
--z-text sobre --z-warning-subtle14.829.314.5
--z-text sobre --z-success-subtle14.769.334.5
--z-text-muted sobre --z-bg5.657.784.5
--z-text-muted sobre --z-surface6.177.134.5
--z-text-muted sobre --z-surface-2 (encabezados)5.806.654.5
--z-text-muted sobre --z-hover5.315.764.5
--z-text-muted sobre --z-danger-subtle (fila atenuada)5.375.144.5
--z-text-muted sobre --z-warning-subtle5.414.904.5
--z-accent sobre --z-surface (enlaces)6.629.334.5
--z-accent sobre --z-bg6.0610.184.5
--z-accent sobre --z-brand-subtle (elemento de menú actual)5.796.394.5
--z-on-primary sobre --z-primary6.6210.184.5
--z-on-primary sobre --z-primary-hover8.8211.264.5
--z-on-primary sobre --z-primary-active11.1212.264.5
--z-on-brand sobre --z-brand (banda del nombre de la app)6.626.624.5
--z-danger sobre --z-surface (errores de campo)6.547.094.5
--z-danger sobre --z-danger-subtle5.695.114.5
--z-warning sobre --z-surface6.338.904.5
--z-warning sobre --z-warning-subtle5.556.124.5
--z-success sobre --z-surface6.528.874.5
--z-info sobre --z-surface6.928.134.5
--z-focus sobre --z-surface6.629.333
--z-focus sobre --z-bg6.0610.183
--z-focus sobre --z-brand-subtle5.796.393
--z-border-strong sobre --z-surface (contorno de entrada)3.784.303
--z-border-strong sobre --z-surface-23.554.013

--z-border es decorativo (divisores entre elementos que ya tienen texto) y no se exige que alcance 3:1.

6. Otros comportamientos de accesibilidad

  • Todo elemento interactivo muestra un anillo :focus-visible de 2 px en --z-focus. Las filas del grid dibujan el anillo dentro de la fila para que el contenedor de desplazamiento nunca lo recorte.
  • Los botones, las entradas, los encabezados de orden, los cuadros de filtro, los botones del paginador y el botón de cierre de ventana miden al menos 24 x 24 px con cualquier densidad.
  • prefers-reduced-motion: reduce elimina la entrada de los toasts y todas las transiciones.
  • En el modo forced-colors, los iconos usan CanvasText, el botón primario y la fila con foco usan Highlight, y el estado que depende de tintes de fondo (elemento de menú actual, estado de fila) recibe en su lugar un contorno o borde.