Módulo 8: Project Build Mercados Design System
Define los tokens finales de Mercado
Descripción
En el módulo 2 fabricaste el set de tokens de Mercado: primitivos por valor, semánticos por rol con override de tema, de componente atados a roles. Cuatro semánticos —color.primary, color.surface, color.muted, color.text— fueron suficientes para probar que la cadena de tres capas resolvía en ambos temas. Esta lección cierra ese set, con los tokens que el sistema completo de Mercado necesita: los cuatro de siempre, más color.text-muted (para el texto secundario del product-card, con el step correcto por tema —lo vas a entender en la lección 4—), color.danger (para la etiqueta "Sale"), y dos que probablemente no habrías anticipado: color.on-primary y color.on-danger.
Esos dos últimos son la pieza nueva de este módulo, y por ahora los vas a tomar con algo de fe: son el color de texto que va sobre un fondo de color (el texto blanco de un botón primary, el texto oscuro de una etiqueta danger). Podrías preguntarte por qué no simplemente escribir text-white y ya —es lo que el Button del módulo 5 hizo—. La respuesta corta es que un texto fijo no sobrevive a los dos temas, y la lección 4 te lo va a probar midiendo, no explicando. Por ahora, define el token; la razón de por qué existe llega en dos lecciones.
Conexión con el módulo. Esta lección integra el módulo 2 completo —las tres capas, resolveToken, el theming por override en la capa semántica— y produce el insumo que todas las lecciones siguientes de este módulo consumen: sin estos tokens no hay bg-primary que conectar en la lección 3, ni contraste que medir en la 4, ni Button que colorear en la 5. Es, literalmente, el cimiento del capstone.
Una analogía: la paleta final del muralista, ampliada para el mural completo
En el módulo 2 preparaste la paleta maestra del muralista: los botes de pigmento etiquetados por rol, con su versión de luz y de penumbra. Esa paleta alcanzaba para pintar una pared de prueba —el product-card aislado—. Pero el mural completo tiene más superficies: una etiqueta de oferta en la esquina de cada pieza, un botón que invita a comprar. El muralista no vuelve a mezclar pigmentos desde cero para esas superficies nuevas —reusa los botes que ya tiene (color.danger sale del mismo rojo que cualquier alerta del sistema) y agrega solo lo que de verdad falta—. Y aquí aparece un detalle que un muralista experimentado sabe y uno novato descubre a la mala: el color de la letra que va sobre un fondo pintado no siempre es el mismo bote. Sobre el azul oscuro de día, la letra blanca se lee perfecto; pero si esa pared se repinta de un azul más claro para la versión nocturna del mural, la misma letra blanca casi desaparece —hace falta un bote de letra distinto para esa pared—. color.on-primary es justamente ese bote: el color de letra correcto para pintar sobre color.primary, que el muralista prepara aparte porque sabe, por experiencia, que "letra clara sobre fondo oscuro" no es una regla fija — depende de qué tan oscuro es el fondo en cada versión del mural.
Ejemplo trabajado: el set completo, en sus tres capas
Aquí está el tokens.js final de Mercado. Ocho primitivos, ocho semánticos (los cuatro conocidos más text-muted, danger, on-primary y on-danger), siete tokens de componente:
// tokens.js — el set final de Mercado: 3 capas, 8 semanticos, 7 tokens de componente.
const tokens = {
// TIER 1 — primitivos: la paleta cruda, sin tema.
primitives: {
'blue.600': '#2563eb', 'blue.400': '#60a5fa',
'gray.50': '#f9fafb', 'gray.100': '#f3f4f6', 'gray.400': '#9ca3af',
'gray.500': '#6b7280', 'gray.800': '#1f2937', 'gray.900': '#111827',
'white': '#ffffff',
'red.500': '#ef4444', 'red.400': '#f87171',
},
// TIER 2 — semanticos: rol -> primitivo, con override por tema.
semantics: {
'color.primary': { light: 'blue.600', dark: 'blue.400' },
'color.surface': { light: 'white', dark: 'gray.900' },
'color.muted': { light: 'gray.100', dark: 'gray.800' },
'color.text': { light: 'gray.900', dark: 'gray.50' },
'color.text-muted': { light: 'gray.500', dark: 'gray.400' }, // step distinto por tema (L4)
'color.danger': { light: 'red.500', dark: 'red.400' },
'color.on-primary': { light: 'white', dark: 'gray.900' }, // el texto SOBRE button.bg
'color.on-danger': { light: 'gray.900', dark: 'gray.900' }, // el texto SOBRE badge.bg
},
// TIER 3 — de componente: pieza -> semantico.
component: {
'card.bg': 'color.surface',
'card.text': 'color.text',
'card.text-muted': 'color.text-muted',
'button.bg': 'color.primary',
'button.text': 'color.on-primary',
'badge.bg': 'color.danger',
'badge.text': 'color.on-danger',
},
};
// resolveToken: sigue la cadena hasta el valor final, segun el tema.
function resolveToken(name, theme) {
if (name in tokens.component) return resolveToken(tokens.component[name], theme);
if (name in tokens.semantics) return resolveToken(tokens.semantics[name][theme], theme);
if (name in tokens.primitives) return tokens.primitives[name];
throw new Error('Token desconocido: ' + name);
}
// emitVars: los tokens semanticos como CSS custom properties de un tema.
function emitVars(selector, theme) {
const lines = [selector + ' {'];
for (const name of Object.keys(tokens.semantics)) {
lines.push(' --' + name.split('.').join('-') + ': ' + resolveToken(name, theme) + ';');
}
lines.push('}');
return lines.join('\n');
}
console.log('=== 1) CSS custom properties de Mercado (final, con on-primary/on-danger) ===\n');
console.log(emitVars(':root', 'light'));
console.log(emitVars('.dark', 'dark'));
console.log('\n=== 2) resolveToken de los 7 tokens de componente, en ambos temas ===');
console.log('token'.padEnd(18) + 'light'.padEnd(12) + 'dark');
console.log('-'.repeat(40));
for (const name of Object.keys(tokens.component)) {
console.log(name.padEnd(18) + resolveToken(name, 'light').padEnd(12) + resolveToken(name, 'dark'));
}
Qué esperar. Al correr node tokens.js, la salida es exactamente esta:
=== 1) CSS custom properties de Mercado (final, con on-primary/on-danger) ===
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-muted: #f3f4f6;
--color-text: #111827;
--color-text-muted: #6b7280;
--color-danger: #ef4444;
--color-on-primary: #ffffff;
--color-on-danger: #111827;
}
.dark {
--color-primary: #60a5fa;
--color-surface: #111827;
--color-muted: #1f2937;
--color-text: #f9fafb;
--color-text-muted: #9ca3af;
--color-danger: #f87171;
--color-on-primary: #111827;
--color-on-danger: #111827;
}
=== 2) resolveToken de los 7 tokens de componente, en ambos temas ===
token light dark
----------------------------------------
card.bg #ffffff #111827
card.text #111827 #f9fafb
card.text-muted #6b7280 #9ca3af
button.bg #2563eb #60a5fa
button.text #ffffff #111827
badge.bg #ef4444 #f87171
badge.text #111827 #111827
Lee la salida con lupa en dos detalles que la lección 4 va a explicar a fondo.
El primero es --color-on-primary: en claro vale #ffffff (blanco) y en oscuro #111827 (casi negro) —el valor se invierte entre temas, algo que ningún otro token semántico de este set hace tan abiertamente—. Compáralo con --color-text, que también cambia de claro a oscuro pero siempre se mantiene en el extremo "oscuro" o "claro" que corresponde a ese tema (texto oscuro en fondo claro, texto claro en fondo oscuro, el patrón normal). on-primary hace lo contrario de lo que uno esperaría a primera vista: en el tema oscuro, el texto sobre el botón es oscuro (#111827). No es un error —es la consecuencia de que color.primary en modo oscuro no es un azul oscuro, es blue.400 (#60a5fa), un azul claro para que resalte sobre el fondo casi negro del tema—. Y un azul claro necesita, para que el texto se lea, una letra oscura encima. La lección 4 te lo va a mostrar con el número exacto.
El segundo es --color-on-danger: #111827 en ambos temas, sin cambiar. A diferencia de on-primary, este token no se invierte —el rojo de color.danger (red.500 en claro, red.400 en oscuro) resulta ser lo bastante parecido en luminosidad en los dos temas como para que el mismo texto oscuro funcione en los dos—. Guarda esta asimetría: no asumas que todo token "on-X" se invierte por tema. Cada par se mide por separado, y el resultado no siempre es el que la intuición predice. Vas a comprobar esto con el algoritmo real de contraste en la próxima lección.
La tabla de resolveToken es la prueba de que la cadena de tres capas —componente → semántico → primitivo— sigue funcionando con el set ampliado: card.text-muted resuelve a #6b7280 en claro y #9ca3af en oscuro (dos grises distintos, no el mismo gris repetido —fíjate que no es el mismo gray.400/gray.500 en ambos, y eso también se explica en la lección 4), y badge.text resuelve al mismo #111827 en las dos columnas, confirmando en la tabla lo que ya viste en el CSS.
Profundización: por qué on-primary no es simplemente "el opuesto de primary"
Es tentador pensar que color.on-primary se puede calcular con una regla mecánica: "si el fondo es oscuro, usa blanco; si es claro, usa negro". Y de hecho eso es casi lo que pasó aquí —pero la palabra importante es "casi". La regla real no es sobre si el fondo es oscuro o claro en abstracto, sino sobre si el fondo, en su valor hexadecimal exacto, da suficiente contraste con cada candidato de texto. blue.600 (#2563eb) y blue.400 (#60a5fa) son los dos "azules", pero uno es mucho más oscuro que el otro —y el punto de corte entre "usa blanco" y "usa negro" no es una intuición, es un número que sale de medir—. Por eso color.on-primary es un token con su propio override de tema, resuelto por separado, y no una fórmula que se calcula al vuelo a partir de color.primary. El sistema no deriva el color de texto correcto — lo fija, una vez, después de medirlo. Eso es exactamente lo que la lección 4 hace: mide los candidatos, y el valor que ves aquí es el resultado de esa medición, no una suposición.
Errores comunes
Poner on-primary como color fijo en vez de como token con tema. Qué pasa: se define const BUTTON_TEXT = '#ffffff' en el código del Button, en vez de un token semántico con { light, dark }. Por qué pasa: "el botón siempre lleva texto blanco" parece una regla razonable a simple vista. Cómo detectarlo: en modo oscuro, el texto del botón se vuelve casi invisible contra el azul claro de color.primary —el mismo bug que la lección 4 va a medir y nombrar—. Cómo corregirlo: color.on-primary vive en la capa semántica, con su propio { light, dark }, exactamente como cualquier otro token de este módulo. El texto del botón no es "siempre blanco"; es "lo que resuelve on-primary en el tema activo".
Asumir que on-danger también se invierte, sin medir. Qué pasa: se copia el patrón de on-primary (invertir claro/oscuro) para on-danger, dando { light: 'gray.900', dark: 'white' } sin verificarlo. Por qué pasa: parece consistente —"si uno se invierte, el otro también"—. Cómo detectarlo: en la lección 4, medir contrastRatio('white', red.400) da un ratio bajo (FAIL) —el rojo oscuro del tema dark sigue siendo demasiado claro para texto blanco—. Cómo corregirlo: cada par se mide por separado; no hay una regla universal de "los colores de acento siempre invierten su texto". on-danger resulta constante (gray.900 en los dos temas) precisamente porque no siguió el mismo patrón que on-primary — y solo lo sabes porque mediste, no porque asumiste.
Nombrar el token de componente con el nombre del semántico repetido. Qué pasa: se escribe component: { 'button.text': 'button.text' } (referencia circular) en vez de apuntar al semántico correcto. Por qué pasa: un error de copiar-pegar al extender el set de tokens de un componente a otro. Cómo detectarlo: resolveToken('button.text', theme) entra en una recursión infinita o lanza un error de "token desconocido". Cómo corregirlo: cada token de componente apunta a un semántico (button.text → color.on-primary), nunca a otro token de componente ni a sí mismo. La cadena siempre baja una capa a la vez.
Ejercicios
Ejercicio 1 — Agrega un token de componente. El SearchBar de Mercado necesita un fondo sutil, distinto del card.bg. Usando los semánticos ya definidos (sin agregar ninguno nuevo), define el token de componente searchbar.bg y ejecuta resolveToken('searchbar.bg', theme) para los dos temas.
Ver solución
// agregar a tokens.component:
'searchbar.bg': 'color.muted',
console.log(resolveToken('searchbar.bg', 'light')); // '#f3f4f6'
console.log(resolveToken('searchbar.bg', 'dark')); // '#1f2937'
color.muted ya existe —es exactamente el rol que describe "un fondo sutil, distinto de la superficie principal"—. No hace falta un semántico nuevo ni un primitivo nuevo: el SearchBar reutiliza un rol que el product-card del módulo 2 ya necesitaba para el mismo propósito. Un token de componente nuevo, cero valores nuevos —el mismo patrón que la extensión "un rol nuevo, cero valores nuevos" del proyecto del módulo 2, aplicado en la dirección inversa: un componente nuevo, cero roles nuevos—.
Ejercicio 2 — Predice antes de correr. Sin ejecutar nada, ¿qué imprime emitVars(':root', 'light') para la línea de --color-text-muted? ¿Y para .dark?
Ver solución
--color-text-muted: #6b7280; (en :root, tema light)
--color-text-muted: #9ca3af; (en .dark, tema dark)
color.text-muted resuelve light → gray.500 → #6b7280 y dark → gray.400 → #9ca3af. Fíjate en que los steps son distintos entre temas —gray.500 en claro, gray.400 en oscuro—, no el mismo step con el mismo hex en los dos. Es la asimetría que se mencionó en el "Qué esperar": la lección 4 mide por qué tiene que ser así y no, por ejemplo, gray.400 en los dos temas.
Ejercicio 3 — Encuentra el error. Este fragmento de tokens.js tiene un bug. Encuéntralo sin correrlo:
semantics: {
'color.on-primary': { light: 'white', dark: 'gray.900' },
},
component: {
'button.text': 'on-primary', // <-- aqui
},
Ver solución
El bug es el nombre en component: dice 'on-primary' mientras que en semantics el token se llama 'color.on-primary' (con el prefijo color.). resolveToken('button.text', theme) buscaría 'on-primary' en tokens.semantics y no lo encontraría —lanzaría Token desconocido: on-primary—. La corrección es usar el nombre completo y consistente: 'button.text': 'color.on-primary'. Es el mismo error de nombrado que "Errores comunes" del módulo 2 advertía para los semánticos —aquí aparece un nivel más abajo, en la referencia de un componente a su semántico—.
Resumen y siguiente paso
En esta lección fijaste el set final de tokens de Mercado: los cuatro semánticos del módulo 2 (primary, surface, muted, text), más text-muted (con steps distintos por tema), danger, y los dos nuevos que este capstone agrega —on-primary y on-danger—, el color de texto correcto para pintar sobre un fondo de color. Viste, en la salida de resolveToken, la primera pista de por qué existen: on-primary se invierte entre temas (blanco en claro, casi negro en oscuro) y on-danger no —una asimetría que no se explica sola, se mide—. Con la paleta ampliada del muralista entendiste que un sistema de diseño maduro no solo tiene "colores de fondo" y "colores de texto" genéricos: tiene colores de texto específicos para cada fondo de color, preparados de antemano.
Antes de avanzar deberías poder: nombrar los ocho semánticos del set final y a qué primitivo apunta cada uno en cada tema; explicar por qué on-primary y on-danger son tokens con override propio y no una fórmula derivada; y ejecutar resolveToken sobre cualquiera de los siete tokens de componente sin mirar la solución.
La lección 3 toma este set y lo conecta a Tailwind: vas a escribir el tailwind.config.js que traduce cada rol (primary, surface, on-primary...) en una utilidad real, y vas a correr tw() —ahora con la pieza que le faltaba en el módulo 3: un colorMap que resuelve cada utilidad de color a su custom property exacta— sobre el product-card completo de Mercado, con su badge de oferta incluido.
Recursos
- Design Tokens Community Group (W3C) — design-tokens.github.io/community-group. El estándar para el set de tokens que acabas de fijar. En inglés.
- MDN, "Using CSS custom properties (variables)" — developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties. El vehículo del CSS que
emitVarsproduce. En inglés. - shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Un set de tokens semánticos con pares "on-X" (
primary-foreground,destructive-foreground) muy cercano al que acabas de construir. En inglés. - WebAIM, "Contrast Checker" — webaim.org/resources/contrastchecker. Adelanto de la lección 4: verifica ahí mismo
#ffffffsobre#60a5fa(elon-primaryde claro sobre elprimaryde oscuro, el par que falla) antes de que el código te lo confirme. En inglés.