Faber Design System / Tokens
Cuatro familias, y el porqué de cada una.
Un token no es un valor con nombre bonito: es una decisión que ya se tomó,
escrita donde no hay que volver a tomarla. Lo que sigue son los valores que
hay hoy en @pantherkit/design-system,
y al lado la razón por la que son esos y no otros.
Todas las muestras de esta página se pintan con var(--token).
Ninguna es una imagen ni un valor copiado: si el paquete cambia, cambian solas.
¿Vienes a leer y no a mirar? /tokens.md.
1 · Color
Cada ratio de esta sección está medido sobre el lienzo real del sistema,
#0b0b0c. El mínimo que exige WCAG AA para
texto normal es 4.5:1; para texto grande, 3:1.
El lienzo: tinta
El sistema entero vive sobre tinta, y no por gusto: la paleta de marca está hecha para fondo oscuro y sobre papel no cumple accesibilidad. El naranja da 2.87:1 sobre blanco y 6.85:1 sobre tinta; el gris de marca, 3.62:1 contra 5.43:1. En oscuro la marca funciona íntegra.
-
--color-bg-primary#0b0b0cEl lienzo. El nombre es semántico a propósito: no dice «negro», dice «el fondo». Por eso invertir el sistema se hace en un archivo y no componente por componente.
-
--color-bg-surface-1#141416 1.07:1 sobre tintaLo que se apoya sobre el lienzo sin despegarse: filas, celdas, campos.
-
--color-bg-surface-2#1c1c1f 1.16:1 sobre tintaLa tarjeta. Es la superficie que usa el botón secundario de fondo.
-
--color-bg-surface-3#26262a 1.31:1 sobre tintaLo que flota: menús, estados hover de una superficie. Sobre tinta la separación entre escalones es de centésimas de contraste — la elevación aquí la hace el borde, no el relleno.
Texto: papel sobre tinta
Tres niveles y ninguno decorativo: cada uno tiene un trabajo. El contraste está medido sobre el lienzo #0b0b0c; el mínimo AA para texto normal es 4.5:1, así que los tres cumplen incluso a tamaño pequeño.
-
--color-text-primary#f5f5f7 18.07:1 sobre tintaEl contenido. Blanco puro no: a 18:1 ya sobra contraste y el blanco absoluto vibra sobre negro.
-
--color-text-secondary#a8a8b0 8.33:1 sobre tintaEl párrafo de apoyo. Baja jerarquía sin bajar de AA.
-
--color-text-muted#86868b 5.43:1 sobre tintaEl gris de marca. Sobre papel da 3.62:1 y no cumple; sobre tinta, 5.43:1 y sí. Es el ejemplo más limpio de por qué el sistema es oscuro.
Acentos: un color, un significado
Naranja = el punto de decisión, donde interviene la persona. Azul = interactividad. No hay un tercer uso para el naranja, y esa es toda la regla.
-
--color-accent#ff6a00 6.85:1 sobre tintaEl naranja de marca. El punto de decisión: el CTA, la cifra que importa, el paso donde entra la persona.
-
--color-accent-light#ff8a33 8.37:1 sobre tintaPara texto naranja pequeño y para hover. Más contraste que el acento, misma familia.
-
--color-accent-muted#3a1f0dSuperficie tenue de acento, no color de texto: 1.29:1 sobre tinta, invisible como letra. Sirve para teñir un panel donde algo naranja va a pasar; el texto primario encima da 13.96:1.
Interactivo
Aquí es donde el sistema toma partido: cuando la marca y la legibilidad chocan, gana la legibilidad, y el porqué se anota al lado del token.
-
--color-link#4da3ff 7.49:1 sobre tintaEl azul de marca es #0071e3 y sobre tinta da 4.19:1: por debajo del 4.5:1 que exige AA. Este cumple con margen. No es un azul «parecido»: es el azul que sí se lee.
-
--color-link-hover#8cc4ff 10.73:1 sobre tintaEl hover aclara. Sobre tinta la respuesta es más luz, no más saturación.
-
--color-borderrgba(255,255,255,0.12)Sobre tinta la línea es luz, no sombra. Un borde oscuro sobre #0b0b0c se lee como un agujero, no como un contorno.
-
--color-border-hoverrgba(255,255,255,0.24)El doble de luz. Es la señal de «esto responde» más barata que tiene el sistema.
Portfolio: el sub-sistema frío
Siete tokens en oklch con el mismo matiz (255) para infraestructura y casos técnicos. Existen para que una sección de arquitectura no tenga que pedirle al naranja que signifique dos cosas. Es la única familia del sistema en oklch: se eligió porque una rampa de superficies necesita pasos perceptualmente iguales, y en hex hay que ajustarlos a ojo.
-
--color-portfolio-surfaceoklch(17% 0.012 255)El lienzo frío de la sección.
-
--color-portfolio-surface-strongoklch(21% 0.02 255)La tarjeta sobre él.
-
--color-portfolio-surface-hoveroklch(25% 0.03 255)Su estado hover.
-
--color-portfolio-pilloklch(24% 0.02 255)El chip de tecnología.
-
--color-portfolio-borderoklch(31% 0.025 255)El filo en reposo.
-
--color-portfolio-border-hoveroklch(45% 0.05 255)El filo cuando responde.
-
--color-portfolio-mutedoklch(72% 0.03 255) 7.94:1 sobre tintaEl único de la familia que lleva texto. Por eso es el único con ratio.
Sombra
Opacidades bajas y radios largos: separan sin dibujar un borde negro. Sobre tinta la elevación casi no se ve, y forzarla es lo que produce el «agujero».
-
--color-shadow-ink#000000La tinta de las sombras, y deliberadamente NO --color-bg-primary. Derivarla del lienzo funciona hoy —sobre #0b0b0c no se distingue— pero el día que el lienzo se aclare la sombra se aclara con él y deja de ser sombra. Una sombra es ausencia de luz, no el fondo.
-
--shadow-sm0 1px 3px rgba(0,0,0,.04)Apoyado.
-
--shadow-md0 4px 16px rgba(0,0,0,.06)Levantado.
-
--shadow-lg0 12px 40px rgba(15,13,10,.10)Flotando. Su tinta tira a cálido, no a negro puro.
Cuando marca y legibilidad chocan
El azul del sistema no es el azul de marca.
Agenda 30 minutos
#0071e3 · el azul de BRAND.md
4.19:1 — no cumple AA
Agenda 30 minutos
--color-link · #4da3ff
7.49:1 — cumple con margen
Es el único hex suelto de toda esta página, y está aquí porque es el que no se usa. Sobre tinta, el azul de marca se queda en 4.19:1 y el mínimo son 4.5:1 — se lee «casi bien», que en accesibilidad significa que alguien no lo lee. El sistema no negoció un azul parecido: eligió el que sí se lee y anotó el porqué junto al token, para que dentro de un año nadie lo «corrija» de vuelta.
El presupuesto del naranja
Uno por sección. Si aparece dos veces, sobra una.
El naranja significa aquí interviene la persona: el CTA, la cifra que decide, el paso donde alguien tiene que hacer algo. Un significado sólo se sostiene mientras sea escaso — dos naranjas en una pantalla no gritan el doble, se anulan, y el ojo deja de saber cuál de los dos era el importante. No es una guía de estilo: es un presupuesto, y se gasta una vez.
Este panel se tiñe con --color-accent-muted y gasta su naranja en un solo sitio: el rótulo. El botón de abajo no es naranja, y es a propósito.
2 · Tipografía
Dos familias y nada más. La jerarquía se construye con tamaño y tracking, no metiendo una tercera fuente.
Una decisión, escrita una vez.
--font-sans
RUN 04 · 250ms
--font-mono
Sin tracking display
letter-spacing: normal — a este tamaño el titular se desarma en palabras sueltas
Con tracking display
--tracking-display: -0.028em — la línea vuelve a leerse como una frase
-
--font-sans'Plus Jakarta Sans', system-ui, sans-serifPara todo. La jerarquía se construye con tamaño y tracking, no metiendo una tercera fuente.
-
--font-mono'JetBrains Mono', ui-monospace, monospaceUna señal técnica, no una alternativa estética: marca índices, duraciones y metadata de documento. Nunca párrafos.
-
--tracking-display-0.028emDe 48px para arriba. El interletraje por defecto de Jakarta está calibrado para texto corrido; a tamaño display abre demasiado y el titular se desarma en palabras sueltas.
-
--tracking-heading-0.012emDe 24 a 40px. Se cierra al crecer, y sólo ahí: en body el tracking negativo pega las letras y cuesta leer.
-
--tracking-subheading-0.012emMismo valor que heading, nombre distinto: el día que un subtítulo tenga que separarse de un H2 se cambia uno sin tocar el otro.
3 · Espacio y radios
--spacing 0.25rem (4px) Una sola raíz de la que sale toda la escala: p-4, gap-6, mt-10 son múltiplos de esto. 4px es el mínimo común divisor de los espaciados que ya usaba la landing (0.5, 0.75, 1, 1.5, 1.75, 2.5, 3.5rem). Con una raíz mayor esos ritmos no se pueden expresar y vuelven los valores sueltos; con una menor la escala deja de restringir nada.
-
×14px -
×28px -
×312px -
×416px -
×624px -
×832px -
×1040px -
×1248px -
×1664px -
×2496px
Cada barra mide calc(var(--spacing) * n). No hay un solo píxel escrito a mano.
El radio comunica escala: cuanto más grande la superficie, más radio necesita para que la curva se lea igual de suave desde la misma distancia. Por eso la escala crece con la pieza y no se elige a ojo.
-
--radius-sm4pxChips y pills pequeñas: apenas matar la esquina.
↑ esta tarjeta usa ese radio
-
--radius-md8pxInputs y botones.
↑ esta tarjeta usa ese radio
-
--radius-lg12pxCards.
↑ esta tarjeta usa ese radio
-
--radius-xl16pxPaneles y contenedores grandes.
↑ esta tarjeta usa ese radio
-
--radius-full9999pxLa píldora: forma, no radio. No se interpola hacia aquí — animar de 12px a 9999px no da una píldora, da una cápsula deformándose a mitad de camino.
↑ esta tarjeta usa ese radio
4 · Movimiento
La física del sistema. Una curva no se documenta con su texto: un
linear() de 23 números no le dice nada a
nadie. Aquí se ven corriendo.
--ease-spring, corriendo
Un oscilador amortiguado real —rigidez 300, amortiguación 22, masa 1— muestreado en 23 puntos. Se pasa un 7.5% de su destino y vuelve. Ese exceso es todo: es lo que hace que el movimiento se lea como materia con masa y no como una interpolación.
La línea es el destino. El cuadro la cruza y retrocede: eso es el 7.5%.
--ease-out-quint y --ease-out-expo no son la misma curva
Se parecen lo bastante como para que ya haya pasado: la ficha de proyecto usaba la quint literal y el patrón en React la había sustituido en silencio por la expo. Nadie lo notó en una captura. Puestas a correr a la vez, la diferencia máxima entre las dos es de 9.5 puntos de recorrido, alrededor del milisegundo 70 de una animación de 600ms — la expo ya está prácticamente colocada cuando la quint todavía viene frenando.
--ease-spring
--ease-out-expo
--ease-out-quint
-
--ease-springlinear(0, 0.0895, … 0.999) · 23 puntosLa curva de la casa, y no es una bezier dibujada a ojo: es un oscilador amortiguado real (rigidez 300, amortiguación 22, masa 1) muestreado en 23 puntos. Pasa de 1 y vuelve — ese rebote es lo que hace que el movimiento se lea como materia con masa y no como una interpolación. Que el enlace y la columna compartan física es lo que hace que una sección se sienta de una pieza y no de tres piezas pegadas.
-
--ease-out-expocubic-bezier(0.16, 1, 0.3, 1)Para lo que NO debe rebotar: entradas de scroll, opacidades, revelados. Sale disparada y frena larguísimo, así que el elemento ya está colocado mucho antes de que la transición termine y no se percibe espera. Sin sobrepaso: en un fade un rebote se ve como un parpadeo.
-
--ease-out-quintcubic-bezier(0.22, 1, 0.36, 1)easeOutQuint. Se parece a la expo pero frena antes y más suave. La ficha de proyecto la usaba literal y el patrón React la había sustituido en silencio por la expo, que no es lo mismo. Tener las dos con nombre es lo que evita que vuelvan a confundirse.
-
--dur-fast90msLa pulsación. Por debajo de ~100ms la respuesta se percibe como causada por el dedo, no como una animación que el sistema decidió tocar. Es el techo del feedback directo: hover, active, foco.
-
--dur-base250msEl cambio de estado normal: abrir, cerrar, entrar, salir. Lo bastante largo para que el ojo siga de dónde viene la cosa, lo bastante corto para no cobrarle la espera a quien ya sabe lo que va a pasar.
-
--dur-medium450msEntre base y slow no había nada, y una hoja que entra en 250ms se siente brusca mientras que 600ms la vuelve pesada. Lo pidieron por su cuenta los workers de varias piezas, así que no era capricho de uno.
-
--dur-slow600msLo que tarda el muelle en asentarse. Es la duración que EXIGE --ease-spring: cortarla antes deja el rebote a medio camino y el elemento aterriza fuera de sitio. Sólo para movimiento con --ease-spring.
-
--animate-fade-upfade-up 0.6s ease bothLa entrada por defecto. Sube 24px y aparece.
-
--animate-fade-infade-in 0.4s ease bothCuando no debe haber desplazamiento, sólo presencia.
-
--animate-slide-in-leftslide-in-left 0.5s ease bothEntrada lateral desde -30px.
-
--animate-slide-in-rightslide-in-right 0.5s ease bothSu espejo, desde +30px.
prefers-reduced-motion no es opcional
El paquete lo aplica global: si el sistema operativo pide menos movimiento,
todas las duraciones bajan a 0.01ms. A cero no —con 0 algunos motores no
disparan transitionend y las secuencias
encadenadas se quedan colgadas. Y el estado final sí se queda: se va el
movimiento, no la señal de que algo respondió. Los botones de esta página
siguen funcionando con la preferencia activada; simplemente el cuadro
aparece ya colocado.