Módulo 2: Design Tokens

Theming: claro y oscuro cambiando el valor del token

Descripción

Aquí se cobra todo lo que sembraste en las cinco lecciones anteriores. Un tema (theme) es un conjunto de valores para tus tokens: el tema claro dice que el fondo es blanco y el texto casi negro; el tema oscuro dice que el fondo es casi negro y el texto casi blanco. La pregunta del millón es cómo se cambia de un tema a otro, y la respuesta —la idea central de esta lección— es tan simple que sorprende: cambias el valor del token semántico, no el componente. El Button, la Card, la SearchBar no se enteran de que existe un tema oscuro. Siguen referenciando color.surface como siempre; lo único que cambia es a qué primitivo apunta color.surface según el tema.

Es el "interruptor semántico" del que venimos hablando desde la presentación del módulo, por fin en acción. En la lección 3 lo dejaste asomar: color.primary tenía { light: 'blue.600', dark: 'blue.400' } —un mismo rol apuntando a distinto primitivo según el tema—. En la lección 4 viste el vehículo: el bloque .dark que redefine las custom properties. En la lección 5 aseguraste la precondición: nombrar por rol, para que color.surface pueda valer blanco o negro sin mentir. Ahora se juntan las tres: vas a ejecutar resolveToken en los dos temas y ver, en una tabla, cómo el mismo token da un valor en claro y otro en oscuro —sin que ningún componente cambie—.

Conexión con el módulo. Esta lección es la culminación del mecanismo de tres capas. Usa la estructura de la lección 3 (primitivos, semánticos, de componente), el vehículo de la lección 4 (custom properties redefinidas por contexto) y la regla de la lección 5 (nombrar por rol). El theming es la prueba viva de por qué el sistema de tokens vale la pena: dos temas completos con un solo override, cero duplicación de componentes. La lección 7 armará la paleta entera de Mercado en ambos temas; la 8 te pondrá a definir los dos temas tú. El detalle de cómo Tailwind expresa esto con su variante dark: es el módulo 6 de la guía.

Una analogía: la obra de teatro con dos iluminaciones

Piensa en una obra de teatro. El escenario tiene su decorado, sus actores, sus muebles —eso no cambia entre función y función—. Lo que cambia es la iluminación: una función se hace con luz de día (cálida, brillante) y otra con luz de noche (fría, tenue). El director no reconstruye el escenario para la función nocturna; cambia los filtros de las luces. Los mismos actores, los mismos muebles, bañados por otra luz.

Tu UI es el escenario; los temas son las iluminaciones. El Button, la Card, la SearchBar son los actores y los muebles: no cambian entre tema claro y oscuro. Lo que cambia son los valores de los tokens semánticos —los filtros de las luces—. color.surface es el filtro "fondo": en la función de día vale blanco, en la de noche vale casi negro. El Button no sabe de filtros; solo está en el escenario, referenciando color.surface, y se ve claro u oscuro según qué filtro esté puesto.

La analogía tiene una moraleja que es exactamente la técnica de esta lección: para cambiar de tema no tocas los actores, cambias la luz. Si tuvieras que reconstruir el escenario para cada iluminación —un Button para claro y otro Button para oscuro— tendrías el doble de decorado que mantener, y el día que cambies el guion tendrías que cambiarlo en dos escenarios. Con un solo escenario y filtros intercambiables, el guion vive una vez y la luz se elige al final. Eso es "theming por tokens": un solo juego de componentes, dos juegos de valores.

El mecanismo: un override por tema

Recuerda la estructura de la lección 3. Un token semántico no guarda un valor; guarda a qué primitivo apunta, y ese apuntado puede depender del tema:

semantics: {
  'color.primary': { light: 'blue.600', dark: 'blue.400' },
  'color.surface': { light: 'white',    dark: 'gray.900' },
  'color.text':    { light: 'gray.900', dark: 'gray.50'  },
}

Lee la fila de color.surface: en tema light apunta a white (#ffffff), en tema dark apunta a gray.900 (#111827). El rol es el mismo —"el fondo de las superficies"— pero el primitivo al que llega cambia. Eso es todo el theming: cada semántico tiene un valor por tema, y resolver un token en un tema u otro sigue la flecha correspondiente.

Fíjate en las capas que no cambian. Los primitivos son los mismos en los dos temas —blue.600 siempre es #2563eb, gray.900 siempre es #111827—; son la caja de pigmentos completa, y ambos temas eligen de ella. Los tokens de componente tampoco cambian: button.bg → color.primary en los dos temas —el botón siempre usa "el color de marca", sea el que sea en cada tema—. La única capa con override por tema es la semántica. Por eso el theming es barato: solo redefines los roles, y las capas de abajo (valores) y de arriba (piezas) quedan intactas.

En CSS, esto es exactamente el bloque .dark de la lección 4: :root da los valores del tema claro y .dark redefine los mismos nombres con los valores oscuros. Un elemento dentro de .dark ve los valores oscuros; fuera, los claros. El componente referencia var(--color-surface) y obtiene uno u otro según el contexto —sin saber cuál—.

Ejemplo trabajado: el mismo token, dos valores

Tomamos el set completo de tres capas y resolvemos varios tokens en ambos temas, lado a lado. La misma función resolveToken de la lección 3 —sin cambiarle una línea— se llama una vez con 'light' y otra con 'dark'. Al final, mostramos la cadena de button.bg en los dos temas para ver dónde se bifurca:

// L6 — el mismo token da distinto valor por tema; el componente NO cambia.
const tokens = {
  primitives: {
    'blue.600': '#2563eb',
    'blue.400': '#60a5fa',
    'gray.50':  '#f9fafb',
    'gray.900': '#111827',
    'white':    '#ffffff',
  },
  semantics: {
    'color.primary': { light: 'blue.600', dark: 'blue.400' },
    'color.surface': { light: 'white',    dark: 'gray.900' },
    'color.text':    { light: 'gray.900', dark: 'gray.50'  },
  },
  component: {
    'button.bg': 'color.primary',
    'card.bg':   'color.surface',
    'card.text': 'color.text',
  },
};

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);
}

function resolveChain(name, theme) {
  const chain = [name];
  let current = name;
  while (true) {
    if (current in tokens.component)       current = tokens.component[current];
    else if (current in tokens.semantics)  current = tokens.semantics[current][theme];
    else if (current in tokens.primitives) { chain.push(tokens.primitives[current]); break; }
    else throw new Error('Token desconocido: ' + current);
    chain.push(current);
  }
  return chain;
}

const names = ['color.primary', 'color.surface', 'color.text', 'button.bg', 'card.bg', 'card.text'];
console.log('token'.padEnd(16) + 'light'.padEnd(12) + 'dark');
console.log('-'.repeat(38));
for (const name of names) {
  console.log(name.padEnd(16) + resolveToken(name, 'light').padEnd(12) + resolveToken(name, 'dark'));
}

console.log('\nMismo button.bg, dos temas (solo cambia a que primitivo llega):');
console.log('  light: ' + resolveChain('button.bg', 'light').join(' -> '));
console.log('  dark:  ' + resolveChain('button.bg', 'dark').join(' -> '));

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

token           light       dark
--------------------------------------
color.primary   #2563eb     #60a5fa
color.surface   #ffffff     #111827
color.text      #111827     #f9fafb
button.bg       #2563eb     #60a5fa
card.bg         #ffffff     #111827
card.text       #111827     #f9fafb

Mismo button.bg, dos temas (solo cambia a que primitivo llega):
  light: button.bg -> color.primary -> blue.600 -> #2563eb
  dark:  button.bg -> color.primary -> blue.400 -> #60a5fa

Lee la tabla columna por columna. La columna light es el tema claro; la dark, el oscuro. Cada fila es un token, y su valor cambia entre columnascolor.surface es #ffffff (blanco) en claro y #111827 (casi negro) en oscuro—. Fíjate en el patrón que forma el fondo y el texto: en claro, fondo claro (#ffffff) y texto oscuro (#111827); en oscuro, se invierten —fondo oscuro (#111827) y texto claro (#f9fafb)—. Eso es un tema oscuro bien hecho: no es "apagar la luz", es intercambiar coherentemente los roles de claridad. Y todo salió de cambiar a qué primitivo apunta cada semántico.

Ahora mira las últimas dos filas de la tabla y compáralas con sus semánticos. button.bg da exactamente lo mismo que color.primary (#2563eb / #60a5fa); card.bg, lo mismo que color.surface; card.text, lo mismo que color.text. No es casualidad: los tokens de componente heredan el valor de su semántico en cada tema, porque button.bg → color.primary y color.primary es quien tiene el override. El botón no tiene lógica de tema propia; toma la del rol que referencia.

Y ahí está la prueba en las dos últimas líneas: la misma cadena, bifurcada en un solo punto. En los dos temas, button.bg apunta a color.primary (idéntico). La diferencia aparece en el eslabón siguiente: en light, color.primary va a blue.600 (#2563eb); en dark, a blue.400 (#60a5fa). El token de componente no cambió, el rol no cambió de nombre; solo cambió a qué primitivo llega el rol. Cambiaste el filtro de la luz, no el actor. Ese único punto de bifurcación —la capa semántica— es donde vive todo el theming.

Una pregunta para razonar el ahorro: ¿cuántos componentes tuviste que escribir dos veces para tener dos temas? (Cero. No hay un Button claro y un Button oscuro; hay un Button que referencia button.bg, y button.bg resuelve distinto por tema. Duplicar el componente sería reconstruir el escenario para cada iluminación —el error que la analogía advierte—. El theming por tokens da dos temas con un override, no con dos juegos de componentes.)

Cómo se activa el tema (y qué es de otro módulo)

Falta una pieza práctica: ¿cómo decide la página qué tema mostrar? En CSS, el patrón de la lección 4 es poner una clase en un ancestro —típicamente <html class="dark">— y que el bloque .dark redefina las custom properties para todo lo que esté dentro. Alternar el tema es, entonces, agregar o quitar esa clase (normalmente con un poco de JavaScript que además recuerda la preferencia del usuario). También existe la media query prefers-color-scheme, que detecta si el sistema operativo del usuario está en modo oscuro, para elegir el tema inicial.

Dónde está la frontera: cómo Tailwind expresa el tema oscuro —su variante dark: y las dos estrategias (class vs media)— es el módulo 6 de esta guía, y el detalle de responsive (que un layout cambie por tamaño de pantalla) también. Aquí nos quedamos en el mecanismo de fondo que los sostiene: un tema es un juego de valores para los tokens semánticos, y cambiar de tema es redefinir esos valores en un contexto. Con eso entendido, la variante dark: del módulo 6 será solo azúcar sobre lo que ya sabes.

Y una precisión importante: el theming no es solo "claro y oscuro". El mismo mecanismo sirve para cualquier conjunto de temas —un tema de alto contraste para accesibilidad, un tema de marca para una campaña, un tema por sub-producto—. Cada tema es una columna más en la tabla que ejecutaste: otro juego de valores para los mismos roles. Claro/oscuro es el caso más común, no el único; la técnica es la misma para todos.

Errores comunes

Duplicar el componente en vez de cambiar el token. Qué pasa: se crea un ButtonDark (o una clase .button--dark) con los colores oscuros escritos, aparte del Button claro. Por qué pasa: es lo más literal —"necesito un botón oscuro, hago un botón oscuro"—. Cómo detectarlo: tienes dos componentes (o dos clases) que solo difieren en color, y cada cambio de forma los tienes que hacer en los dos. Cómo corregirlo: es reconstruir el escenario para cada iluminación. El theming por tokens existe justo para evitarlo: un Button que referencia button.bg, y button.bg resuelve claro u oscuro según el tema. Duplicar el componente multiplica el mantenimiento por el número de temas; referenciar tokens lo deja en uno. Si te descubres creando un XDark, esa es la señal de que ahí falta un token con override por tema.

Hacer el override en la capa equivocada. Qué pasa: se ponen los valores por tema en los primitivos (blue.600 distinto en claro y oscuro) o en los tokens de componente (button.bg con override propio). Por qué pasa: no queda claro cuál de las tres capas es la que "sabe" del tema. Cómo detectarlo: tienes primitivos o tokens de componente con { light, dark }, y el theming se siente disperso y contradictorio. Cómo corregirlo: solo la capa semántica tiene override por tema. Los primitivos son valores fijos (la caja de pigmentos, igual en todo tema); los tokens de componente heredan del semántico. Si un primitivo cambia por tema, dejó de ser un valor crudo y se volvió un rol mal ubicado —blue.600 no puede valer dos azules distintos, o no es blue.600—. El tema vive en el medio: primitivos abajo (fijos), semánticos con override (el tema), componentes arriba (heredan).

Nombrar por valor y descubrir que el tema oscuro es imposible. Qué pasa: se nombró el fondo white (por valor, no por rol), y al llegar el tema oscuro resulta que white tendría que valer #111827 —un token llamado white que vale negro—. Por qué pasa: se saltó la regla de la lección 5. Cómo detectarlo: tus semánticos tienen nombres de color y el tema oscuro te obliga a escribir absurdos como white = #111827. Cómo corregirlo: esta es la venganza de nombrar por valor —el theming lo vuelve imposible, no solo feo—. El fondo debe llamarse color.surface (por rol) precisamente para que pueda valer blanco en un tema y negro en otro sin mentir. Si te topas con white valiendo negro, el arreglo no es el tema: es renombrar el token por su rol (lección 5) antes de intentar el segundo tema.

Ejercicios

Ejercicio 1 — Resuelve en ambos temas. Con el set del ejemplo trabajado, sin correr nada, di el valor de cada token en tema claro y oscuro:

  • (a) color.text
  • (b) card.bg
  • (c) color.primary
Ver solución

Siguiendo las flechas del set en cada tema:

TokenlightdarkCómo
color.text#111827#f9fafbsemántico: light → gray.900, dark → gray.50
card.bg#ffffff#111827componente → color.surface, que es white / gray.900
color.primary#2563eb#60a5fasemántico: light → blue.600, dark → blue.400

Fíjate en card.bg: es de componente, así que hereda de color.surface —no tiene valores propios por tema, toma los del rol que referencia—. El único que "decide" el tema es el semántico del medio.

Ejercicio 2 — Predice la tabla con un token nuevo. Sin correr nada, di qué dos valores (light, dark) mostraría la tabla para un token de componente searchbar.bg definido así, dado el set del ejemplo:

tokens.component['searchbar.bg'] = 'color.surface';
Ver solución

searchbar.bg es de componente y apunta a color.surface. resolveToken sigue la cadena searchbar.bg → color.surface → (white | gray.900) → valor, así que da exactamente lo mismo que color.surface y que card.bg:

searchbar.bg    #ffffff     #111827

Igual que card.bg, hereda del semántico color.surface: blanco en claro, casi negro en oscuro. Dos tokens de componente que apuntan al mismo semántico siempre resuelven igual —comparten el filtro—. Si quisieras que la SearchBar tuviera un fondo ligeramente distinto del de las tarjetas, la apuntarías a otro semántico (por ejemplo color.muted), no le pondrías valores propios por tema.

Ejercicio 3 — Agrega un tercer tema. El mecanismo no se limita a claro/oscuro. Agrega un tema highContrast al semántico color.text y color.surface para máxima legibilidad (texto negro puro sobre blanco puro), asumiendo que existen los primitivos black = #000000 y white = #ffffff. Muestra cómo quedarían esos dos semánticos y explica qué tuviste que tocar en los componentes.

Ver solución

Agregas una clave más —highContrast— a cada semántico, junto a light y dark:

semantics: {
  'color.surface': { light: 'white',    dark: 'gray.900', highContrast: 'white' },
  'color.text':    { light: 'gray.900', dark: 'gray.50',  highContrast: 'black' },
  // ...color.primary tambien necesitaria su clave highContrast
}

Y ahora resolveToken('card.text', 'highContrast') seguiría card.text → color.text → black → #000000, dando negro puro. Qué tuviste que tocar en los componentes: nada. El Button, la Card y la SearchBar siguen referenciando sus tokens; agregar un tema es agregar una columna de valores a los semánticos, no un juego nuevo de componentes. Ese es el punto que cierra el módulo: un tema es un conjunto de valores para los mismos roles, y el sistema soporta cuantos temas quieras sin duplicar una sola pieza. (Habría que dar highContrast a todos los semánticos para que el tema esté completo; el resolveToken fallaría al pedir un token cuyo semántico no tiene esa clave —un buen recordatorio de que un tema debe cubrir todos los roles—.)

Resumen y siguiente paso

En esta lección viste el pago de todo el módulo: para cambiar de tema cambias el valor del token semántico, no el componente. Con la obra de teatro y sus dos iluminaciones entendiste el principio —los actores y muebles (los componentes) no cambian; cambian los filtros de las luces (los valores de los semánticos)—. Viste que solo la capa semántica tiene override por tema: los primitivos son fijos (la caja de pigmentos) y los de componente heredan. Y lo comprobaste ejecutando resolveToken en los dos temas: el mismo color.surface dio blanco en claro y casi negro en oscuro, el fondo y el texto se invirtieron coherentemente, y la cadena de button.bg se bifurcó en un solo punto —la capa semántica— sin tocar el componente. Dos temas completos con un override, cero componentes duplicados.

Antes de avanzar deberías poder: explicar qué es un tema y cómo se cambia de uno a otro; decir qué capa tiene el override por tema y por qué las otras dos no; trazar cómo un token de componente hereda el valor de tema de su semántico; y conectar esto con la regla de nombrar por rol (sin ella, el tema oscuro es imposible).

La lección 7 junta todo lo del módulo en el caso real: la paleta de Mercado, las tres capas en la práctica. Vas a ver el set completo del storefront —todos sus primitivos, sus semánticos con ambos temas, sus tokens de componente— y resolverlo entero en claro y oscuro. Es el ensayo general antes del proyecto: el sistema de tokens de Mercado, armado y ejecutado de punta a punta.

Recursos