Documentación

@yoltra/ds

El Sistema de Diseño de Yoltra: tokens de fundación, temas semánticos claro/oscuro, un generador de hoja de estilos basada en variables CSS y componentes primitivos de React compartidos por el sitio web, la documentación y los ejemplos de Yoltra.

#Instalación

bash
npm install @yoltra/ds

#Uso

Inyecta la hoja de estilos una vez en la raíz de tu app y usa los primitivos donde quieras:

tsx
import { themeCss, Button, Callout, CodeBlock } from "@yoltra/ds";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es" data-theme="light">
      <head>
        <style dangerouslySetInnerHTML={{ __html: themeCss() }} />
      </head>
      <body className="yl-root">{children}</body>
    </html>
  );
}

El tema se controla mediante el atributo data-theme="light" | "dark" en la raíz del documento. Como el DS resuelve los colores con variables CSS, los primitivos se renderizan en el servidor; solo los controles interactivos (cambio de tema, pestañas, botón de copiar) son componentes de cliente.

#Contenido

ExportPropósito
foundationTokensEscala primitiva: paleta, tipografía, espaciado, radios, elevación, movimiento.
lightTheme / darkTheme / themesMapeos semánticos de roles.
themeCss()Emite las propiedades personalizadas --yl-* de ambos temas. Solo propiedades, sin reglas de componentes.
ThemeProvider / useTheme / applyThemeControlador genérico de tema.
noFlashScript() / THEME_STORAGE_KEYEl script en línea que restaura el tema antes del primer pintado, y la clave que comparte con el proveedor.
Heading, Text, Link, InlineCode, KbdTipografía.
Button, ButtonLink, IconButton, ButtonGroupAcciones.
Input, Textarea, Select, Checkbox, Radio, RadioGroup, Switch, Slider, Label, FormField, FieldsetControles de formulario y su etiquetado.
Card, Container, Stack, Inline, Grid, Divider, AuthCardMaquetación y composición.
Badge, Chip, Callout, Stat, StatGridEstado y cifras.
Spinner, Skeleton, ProgressBar, EmptyStateRetroalimentación: indeterminada, determinada, y cuando no hay nada que mostrar.
Table, TableScroll, THead, TBody, TR, TH, TDPartes presentacionales de tabla.
CodeBlock, Tabs, VisuallyHiddenTodo lo demás.
Portal, Dialog, DrawerOverlays modales, renderizados fuera del árbol. Ver más abajo.
Popover, Menu, MenuItem, MenuSeparator, ContextMenu, TooltipOverlays anclados, posicionados contra un trigger o un punto.
useFocusTrap, useDismiss, useReturnFocus, useScrollLock, focusableWithin, resolvePlacementLos comportamientos con los que están hechos esos overlays, para una superficie que no cubren.
useControllableStateUn valor que es del llamador o del componente, para construir un control propio.

Cada componente tiene su README.md al lado de su código, con ejemplos ejecutables y su contrato de accesibilidad: src/primitives/Button/README.md y así con cada uno. Están en inglés, como el resto del código.

Quien ya es dueño de su estado (como el sitio de Yoltra, que maneja el tema desde un store de Yoltra) puede prescindir de ThemeProvider y fijar data-theme por su cuenta: el contrato con el DOM es el mismo.

#Cómo encaja todo

Los tokens se escriben una sola vez, en TypeScript, y se convierten en custom properties de CSS. Las hojas de estilo de los componentes leen solo los roles semánticos, y un único atributo data-theme en la raíz del documento decide a qué valor resuelve cada rol. El tema es CSS y no estado de React, por eso las primitivas se renderizan en el servidor.

Cómo los tokens se vuelven estilos con tema. Los tokens y temas escritos en TypeScript se vuelven las propiedades --yl-* en tokens.css o en una etiqueta style, y el atributo data-theme elige los valores claros u oscuros.Cómo los tokens se vuelven estilos con tema. Los tokens y temas escritos en TypeScript se vuelven las propiedades --yl-* en tokens.css o en una etiqueta style, y el atributo data-theme elige los valores claros u oscuros.

#Marca

Azul primario #1A7FE2, carbón #0F172A. Tipografía: Inter + JetBrains Mono.

#Instalando los estilos

El sistema de diseño publica una hoja de estilos por componente, de modo que una aplicación carga los estilos de lo que importa y nada más. Dos hojas hacen falta siempre; el resto son opcionales.

ts
import "@yoltra/ds/styles/tokens.css";   // las custom properties, ambos temas
import "@yoltra/ds/styles/base.css";     // la raíz de 10px, .yl-root, .yl-container
import "@yoltra/ds/styles/button.css";   // una por cada componente que uses
import "@yoltra/ds/styles/badge.css";

@yoltra/ds/styles/all.css las lleva todas, para un sitio de documentación o un prototipo donde el intercambio no vale la pena.

Es deliberado. El JavaScript se sacude (el presupuesto de tamaño lo demuestra), pero una sola hoja con las reglas de todos los componentes no puede, así que se vuelve un costo que toda aplicación paga sin importar lo que renderice.

themeCss() se sigue exportando y emite las mismas custom properties, para un render de servidor que necesite inyectarlas en línea en vez de enlazarlas.

#1rem son 10px

base.css define html { font-size: 62.5% }, lo que deja la raíz en 10px y hace que cada longitud del sistema se lea como su valor en píxeles dividido entre diez: 1.6rem son 16px, 0.4rem son 4px. Los tokens se escriben en píxeles, porque que spacing[4] sea 16 es más fácil de razonar que 1.6, y solo el valor emitido lleva la unidad.

rem en lugar de px para que la preferencia de tamaño de fuente del lector siga escalando la interfaz. La raíz más pequeña hace legible la aritmética; no vuelve fijo el tamaño.

De ahí se siguen tres cosas, y son fáciles de equivocar:

  • Los breakpoints van en píxeles. rem dentro de una media query se resuelve contra el tamaño de fuente raíz inicial, no contra este, así que un breakpoint en rem sería silenciosamente 1.6× lo que aparenta.
  • Los bordes de un pixel van en píxeles. 0.1rem invita al redondeo subpíxel; un borde de 1px debería medir 1px.
  • La declaración de raíz es global. Afecta al documento entero, no solo a los componentes de Yoltra. Una aplicación que no pueda aceptarlo debería importar @yoltra/ds/styles/base-no-root.css y definir su propia raíz, momento en el cual cada longitud --yl-* queda relativa a lo que esa aplicación elija.

#Overlays

Dialog y Drawer se renderizan a través de Portal dentro de un nodo bajo document.body, en vez de donde están escritos. No es una decisión estética: renderizar en el sitio pierde contra el CSS de tres maneras distintas, y ninguna se arregla con z-index:

  • un ancestro con overflow: hidden recorta el panel;
  • un ancestro con transform, filter o will-change se convierte en el bloque contenedor de position: fixed, así que un diálogo "centrado en el viewport" queda centrado en ese ancestro;
  • un ancestro que estableció un contexto de apilamiento atrapa al overlay debajo de lo que esté encima de ese ancestro.

Portar al body deja al overlay compitiendo únicamente con el orden de apilamiento del documento, que es lo que describen los tokens --yl-z-*. Su orden codifica contención: un popover abierto dentro de un diálogo queda por encima de él, y un tooltip por encima de ambos.

#El nivel modal

Dialog y Drawer son controlados (open y onClose son todo el contrato de estado) y traen el comportamiento que hace a un overlay usable en vez de meramente visible:

ComportamientoQué evita
Foco atrapado dentro de la superficieQue Tab salga del modal hacia la página de atrás
Foco restaurado al cerrarQue el siguiente Tab empiece desde el inicio del documento
Scroll de página bloqueado, con conteo de referenciasQue la página de atrás se desplace bajo el panel; y que el bloqueo sobreviva al último overlay
Descarte con Escape y con clic fuera, apiladoQue una sola tecla cierre el menú y el diálogo que está detrás

title es obligatorio, no opcional. Un modal sin nombre accesible se anuncia como "diálogo" y nada más, que es la forma más común de equivocar este componente; envuélvelo en VisuallyHidden si el diseño no lleva encabezado visible.

tsx
import { Dialog } from "@yoltra/ds/client";
import "@yoltra/ds/styles/modal.css";

<Dialog open={open} onClose={close} title="Dar de baja satélite" description="Esto no se puede deshacer.">
  <Text>SAT-04 dejará de reportar telemetría de inmediato.</Text>
</Dialog>;

#El nivel anclado

Popover, Menu y ContextMenu no son modales: se sientan junto a la página en vez de encima, así que no atrapan ni bloquean nada. Se cierran con Escape, con un clic fuera, y cuando el foco se va hacia algo que no es ni la superficie ni su trigger. Menu añade el patrón de teclado de menú: el foco pasa al primer elemento al abrir y rota con las flechas, Home y End, dando la vuelta en ambos extremos; Tab cierra y sigue más allá del trigger.

Cada uno recibe un render prop trigger y le entrega el cableado ARIA:

tsx
<Menu
  open={open}
  onClose={() => setOpen(false)}
  label="Acciones del satélite"
  trigger={(props) => <Button {...props} onClick={() => setOpen((v) => !v)}>Acciones</Button>}
>
  <MenuItem onSelect={deploy}>Desplegar paneles</MenuItem>
  <MenuItem onSelect={boost} disabled>Elevar órbita</MenuItem>
</Menu>

Entregar aria-expanded, aria-haspopup y aria-controls en vez de documentarlos es deliberado: ese cableado es el paso que se salta, y entonces un lector de pantalla describe un botón que aparenta no hacer nada.

Un MenuItem deshabilitado lleva aria-disabled, no el atributo disabled, para que las flechas sigan llegando a él. Que te digan que una acción no está disponible es mejor que no poder enterarte de que existe.

ContextMenu se ancla a un punto en lugar de a un elemento: at={{ x, y }} desde un evento contextmenu, o null cuando está cerrado. La aritmética de posicionamiento trata un punto como un rectángulo de tamaño cero, así que voltea y recorta cerca de los bordes de la ventana igual que un menú anclado a un elemento.

Tooltip es la excepción a la regla de "controlado": su visibilidad pertenece al puntero y al anillo de foco, no al estado de la aplicación. Nunca toma el foco, y se cablea con aria-describedby en vez de aria-label; etiquetar con un tooltip deja un botón de ícono cuyo nombre desaparece cuando el tooltip lo hace.

#Posicionamiento

resolvePlacement se exporta y es puro. Voltea únicamente cuando el lado opuesto realmente cabe; "el lado con más espacio" suena equivalente pero mueve un overlay más alto que la ventana por unos pocos puntos porcentuales de área visible, lo que no compra nada y vuelve impredecible la posición. El recorte aplica solo al eje transversal; recortar el eje principal deslizaría el overlay encima del mismísimo elemento que describe.

Las posiciones son coordenadas de viewport contra position: fixed, así que no hay aritmética de offset-parent, la fuente habitual de los bugs "correcto en todas partes menos dentro de ese panel con scroll".

Una limitación que conviene conocer. El overlay es position: fixed, así que un ancestro de la raíz del portal con un transform aún lo capturaría, pero la raíz del portal es hija directa de document.body, con lo que en la práctica eso significa un transform sobre <body> mismo.

#Tamaño

Medido como lo publica un consumidor (empaquetado, sacudido, minificado, comprimido con gzip) y verificado por rush size en cada build.

ImportTamañoPresupuesto
{ Button, Card, Stack, Text }0.9 KB1.2 KB
todo6.4 KB8 KB
{ Dialog } desde /client1.8 KB3 KB
todo /client4.8 KB5.5 KB

La distancia entre las filas de barrel y las de import nombrado es el tree-shaking funcionando: { Dialog } no se movió cuando aterrizó el nivel anclado, aunque el barrel de cliente creció dos tercios. La cifra del barrel es un detector de crecimiento, no un costo que alguien pague; import * as all no es algo que la gente escriba.

Estos números son menores que antes de separar la hoja de estilos, y eso no es una mejora: el CSS no encogió, salió del bundle de JavaScript hacia archivos que importas deliberadamente. Suma las hojas de los componentes que uses al comparar.

#Tokens

Tres niveles, y un componente solo puede leer el del medio.

Primitivos: valores crudos, --yl-space-4, --yl-radius-md, --yl-breakpoint-lg, --yl-text-h1-size, --yl-motion-duration-fast. No cargan intención, así que sobreviven un cambio de tema porque no participan en él.

Roles semánticos: dicen para qué sirve un color, y son los únicos colores que una hoja de estilos debería nombrar: --yl-color-bg-canvas, --yl-color-fg-muted, --yl-color-interactive-bg, --yl-color-status-error-fg. Cada uno tiene un valor claro y uno oscuro, así que un componente que lee un rol queda tematizado sin hacer nada más. --yl-color-interactive-track es la parte sin llenar de un control, el carril por el que se desliza la perilla de un switch, y mantiene 3:1 contra la perilla en ambos temas.

Locales del componente: viven en la hoja del propio componente, sin el prefijo --yl-, y derivan de un rol. El ancho de un diálogo no le importa a nadie más.

La paleta no se emite, deliberadamente. Una hoja de estilos que puede alcanzar primary[500] ya se saltó la capa que hace funcionar el tema, y toda pregunta que el CSS hace en realidad es "cuál azul para qué".

#Los tokens que nadie usa

La disciplina con el color suele cuidarse sola. Todo lo demás se reescribe a mano, así que vale la pena decir con claridad lo que ya existe:

En lugar deUsa
z-index: 10--yl-z-base, --yl-z-sticky, --yl-z-overlay, --yl-z-popover, --yl-z-tooltip
box-shadow: 0 12px 40px rgb(0 0 0 / 25%)--yl-elevation-xs … --yl-elevation-xl
transition: all 0.2s ease--yl-motion-duration-{fast,normal,slow} con --yl-motion-ease-{standard,emphasized,decelerated}
@media (min-width: 768px)--yl-breakpoint-{sm,md,lg,xl}
max-width: 720px--yl-container-{md,lg,xl}
font-variant-numeric: tabular-nums--yl-font-numeric, para que las cifras que cambian no muevan su propia columna
font-weight: 650--yl-font-weight-{regular,medium,semibold,bold,extrabold}
border: 1px solid--yl-border-width-{thin,medium,thick}

Un z-index elegido a mano es correcto hasta el día en que dos de ellos se encuentran. El resto es la misma historia en otra unidad.

#La escala tipográfica

Doce roles, cada uno emitido eje por eje para que quien lo use pueda tomar el tamaño sin heredar el peso:

css
.title {
  font-size: var(--yl-text-h2-size);
  font-weight: var(--yl-text-h2-weight);
  line-height: var(--yl-text-h2-leading);
  letter-spacing: var(--yl-text-h2-tracking);
}

Roles: hero, h1–h4, body-lg, body, body-sm, label, button, caption, code. Ejes: size, weight, leading, tracking, family, transform. Un eje que un rol no define no se emite, así que no puede sobrescribir un valor heredado con nada.

#El tema oscuro es la marca

Sus superficies no están elegidas a mano. Cada una se mezcla a partir del par de marca, carbón #0F172A y el azul de marca más profundo #123F68, para que una interfaz oscura se lea como negro azulado y no como gris neutro, y para que un cambio de marca mueva el tema completo en vez de dejarlo atrás:

ts
panel    = carbón + 16% de azul de marca
canvas   = eso, 34% hacia el negro
subtle   = eso, 16% hacia el negro
inset    = eso, 52% hacia el negro
elevated = eso, 30% hacia neutral[800]

El tema claro lleva la marca en sus acentos, que es donde corresponde sobre una página blanca: rellenos interactivos, enlaces, anillos de foco y un lienzo teñido con primary[50].

#Color de marca y contraste

--yl-color-brand-primary es #1A7FE2. Con 4.06:1 sobre blanco alcanza para un logo o tipografía de despliegue y no alcanza para texto corrido, así que el texto que debe verse de marca lee --yl-color-fg-brand, un paso más oscuro, con 5.38:1. Los rellenos de botón usan --yl-color-interactive-bg, que es el mismo paso.

tests/contrast.test.ts calcula cada combinación en ambos temas y falla por debajo de 4.5:1 para texto o 3:1 para un anillo de foco o un acento de estado. El texto deshabilitado está exento, por WCAG 1.4.3, y se verifica que siga por debajo del umbral para que se siga viendo deshabilitado.

#Migrar desde 0.3.x

La migración desde 0.3.x tiene su propia página: Actualizar a 0.4.

#Autoría de estilos

Los estilos de los componentes son SASS, en src/primitives/<Componente>/<Componente>.scss y src/overlay/<Componente>/<Componente>.scss, compilados a un archivo por componente por scripts/build-styles.mjs. El nombre compilado sale del basename, así que Button/Button.scss se sigue publicando como button.css.

SASS nunca es dueño de un valor. Los colores, espaciados y radios se leen como var(--yl-*), porque el tema es un cambio de atributo data-theme en tiempo de ejecución sobre la raíz del documento, y una variable SASS se compila mucho antes de que ese cambio ocurra. Lo que SASS aporta es anidamiento, archivos por componente y los mixins compartidos. Un componente empieza con @use "../styles" as *; y los tiene todos a mano: focus-ring, focus-field, visually-hidden, media-up, reduced-motion, disabled-text, disabled-control, transition, surface, y type(), que aplica un rol tipográfico completo de una vez.

stylelint lo hace cumplir: un hex, un rgba(), un tamaño de fuente literal o un peso de fuente literal en la hoja de un componente es un error de lint. Usa un rol o un token en su lugar.

#Licencia

MIT © Manu Ramirez

Reportar un problema con esta página