Módulo 2: Design Tokens

Primitivos, semánticos y de componente: las tres capas de tokens

Descripción

Hasta aquí todos los tokens fueron planos: cada uno era, directamente, un valor. color.primary → #2563eb, punto. Eso funciona para explicar la idea, pero un sistema real no organiza sus tokens así, y esta lección es la que explica por qué. Los tokens de un sistema se estructuran en tres capasprimitivos, semánticos y de componente— y cada capa apunta a la de abajo. Un token de componente apunta a uno semántico, que apunta a uno primitivo, que finalmente es un valor. Recorrer esa cadena hasta el valor final es lo que hace la función estrella del módulo: resolveToken.

Esta estructura de tres capas es, sin exagerar, la idea más importante del módulo entero. Todo lo que sigue depende de ella: el theming claro/oscuro (lección 6) funciona porque un token semántico puede apuntar a distinto primitivo según el tema; la regla de nombrar por rol (lección 5) es sobre la capa semántica; la paleta de Mercado (lección 7) es un set de tres capas. Si entiendes bien la cadena button.bg → color.primary → blue.600 → #2563eb —qué hace cada eslabón y por qué está ahí—, el resto del módulo encaja solo.

Conexión con el módulo. La lección 2 definió el token como un par nombre → valor plano; esta le da su estructura real de tres capas y el resolveToken que la recorre. Es el mecanismo central: la lección 4 lo baja al navegador (cómo se ven las tres capas en CSS custom properties), la 5 profundiza la capa del medio (nombrar el semántico por rol), la 6 lo pone a hacer theming (el semántico apunta a distinto primitivo por tema), y la 7 y 8 arman el set completo. La cadena de tres eslabones que construyes aquí es el esqueleto de todo el módulo.

Una analogía: del papel al pigmento en la paleta del pintor

Vuelve a la paleta del pintor, porque ahora la analogía muestra sus tres niveles con precisión.

En el nivel más bajo están los pigmentos crudos, en sus tubos, con su nombre químico: "azul de ftalocianina", "blanco de titanio". Un pigmento es un valor exacto y objetivo —siempre es el mismo azul—, pero no dice para qué sirve en tu cuadro. El azul de ftalocianina no sabe si lo vas a usar para un cielo, para un mar o para el vestido de un personaje. Es materia prima pura. Esos son los tokens primitivos: blue.600 = #2563eb. Un nombre para un valor crudo, sin opinión sobre su uso.

En el nivel del medio están las etiquetas por su papel en el cuadro: el pintor toma un pocillo y le pega la etiqueta "cielo", y decide que "cielo" usa azul de ftalocianina. La etiqueta no es un pigmento nuevo —no inventó un color—; es un rol que apunta a un pigmento. Y aquí está la magia: la etiqueta "cielo" puede apuntar a la ftalocianina hoy y a un azul más cálido mañana. El rol es estable ("siempre habrá un cielo"), pero a qué pigmento apunta puede cambiar. Esos son los tokens semánticos: color.primary → blue.600. Un rol que apunta a un primitivo.

En el nivel de arriba están las partes concretas del cuadro atadas a un rol: "el marco usa el color de 'acento'", "la firma usa el color de 'texto'". No inventan color ni rol; atan una pieza específica a un rol existente. Esos son los tokens de componente: button.bg → color.primary. Una pieza que apunta a un semántico.

Cuando el pintor moja el pincel para el marco, sigue la cadena sin pensarlo: marco → usa acento → que apunta a ftalocianina → que es #2563eb. Del papel (la pieza) al rol (el semántico) al pigmento (el primitivo) al valor. Esa es, exactamente, la cadena que resolveToken recorre. Y la razón de tener tres niveles en vez de uno es la misma por la que el pintor los tiene: para poder cambiar el pigmento del cielo sin repintar, o repintar solo el marco sin tocar el cielo. Cada nivel se cambia por su cuenta.

Las tres capas, una por una

Veámoslas de abajo hacia arriba, que es como se construyen.

Capa 1 — Primitivos: los valores crudos. Un primitivo es un nombre para un valor exacto, sin rol. blue.600 = #2563eb, gray.900 = #111827, white = #ffffff. Se nombran por lo que son —una familia de color y un paso de intensidad (blue.600, blue.400)—, no por para qué sirven. Un primitivo no sabe si es el color de marca o el de un enlace; es solo "el azul 600". Son la caja de pigmentos completa: todos los colores que el sistema podría usar. Normalmente hay muchos primitivos y solo algunos terminan usándose.

Capa 2 — Semánticos: los roles. Un semántico es un nombre para un rol de diseño que apunta a un primitivo. color.primary → blue.600, color.surface → white, color.text → gray.900. Se nombran por lo que hacen en la UI —el color de marca, el fondo de las superficies, el color del texto—, no por su valor. La capa semántica es la que da sentido: traduce "el azul 600" a "el color de marca". Y es la capa que más importa, porque es la que las piezas y las utilidades deberían referenciar, y la que cambia en el theming. Un semántico nunca es un valor crudo; siempre apunta a un primitivo.

Capa 3 — De componente: las piezas atadas a un rol. Un token de componente ata una parte concreta de una pieza a un rol semántico. button.bg → color.primary (el fondo del botón usa el color de marca), card.bg → color.surface (el fondo de la tarjeta usa el color de superficie). Son opcionales —muchos sistemas paran en la capa semántica— pero útiles cuando una pieza necesita poder ajustarse sin tocar el rol global: puedes cambiar button.bg a otro semántico sin afectar a nadie más que al botón. Un token de componente siempre apunta a un semántico, nunca directo a un primitivo.

La dirección es obligatoria y va siempre hacia abajo: componente → semántico → primitivo → valor. Nunca al revés. Un primitivo no sabe de roles; un semántico no sabe de botones. Esta es la misma regla del flujo hacia arriba del módulo 1 (cada capa usa solo las de abajo), ahora dentro de la capa de tokens. Y es lo que hace que la cadena sea resoluble: empiezas en cualquier token y sigues las flechas hasta llegar a un primitivo, que es donde vive el valor de verdad.

  button.bg          card.bg          card.text        <- TIER 3 componente
     │                  │                 │               (pieza -> semantico)
     ▼                  ▼                 ▼
  color.primary     color.surface     color.text       <- TIER 2 semantico
     │                  │                 │               (rol -> primitivo)
     ▼                  ▼                 ▼
  blue.600          white             gray.900          <- TIER 1 primitivo
     │                  │                 │               (nombre -> valor crudo)
     ▼                  ▼                 ▼
  #2563eb           #ffffff           #111827           <- el valor final

Ejemplo trabajado: resolveToken recorre la cadena

Modelamos las tres capas como tres objetos, y escribimos resolveToken(name, theme): dado el nombre de un token cualquiera, sigue la cadena hasta el valor final. La lógica es una recursión limpia —si el nombre es de componente, resuelve a lo que apunta; si es semántico, resuelve al primitivo del tema; si es primitivo, ese es el valor—. Añadimos también resolveChain, que hace lo mismo pero devolviendo cada eslabón para poder verlo. (El theme ya aparece aquí porque el semántico lo necesita; en esta lección solo usamos 'light' —el theming completo es la lección 6—.)

// L3 — las 3 capas de tokens y resolveToken: seguir la cadena hasta el valor final.
const tokens = {
  // Tier 1 — primitivos: valores crudos, sin tema, sin referencias.
  primitives: {
    'blue.600': '#2563eb',
    'blue.400': '#60a5fa',
    'gray.50':  '#f9fafb',
    'gray.900': '#111827',
    'white':    '#ffffff',
  },
  // Tier 2 — semanticos: apuntan a un primitivo; pueden cambiar por 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'  },
  },
  // Tier 3 — de componente: apuntan a un semantico (mismo target en todo tema).
  component: {
    'button.bg': 'color.primary',
    'card.bg':   'color.surface',
    'card.text': 'color.text',
  },
};

// resolveToken: sigue la cadena componente -> semantico -> primitivo -> valor.
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);
}

// resolveChain: la misma cadena, pero mostrando cada eslabon.
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;
}

console.log('=== resolveToken en tema light: la cadena de las 3 capas ===\n');
for (const name of ['button.bg', 'card.bg', 'card.text']) {
  console.log('  ' + resolveChain(name, 'light').join('  ->  '));
}
console.log('\n  button.bg resuelve a: ' + resolveToken('button.bg', 'light'));

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

=== resolveToken en tema light: la cadena de las 3 capas ===

  button.bg  ->  color.primary  ->  blue.600  ->  #2563eb
  card.bg  ->  color.surface  ->  white  ->  #ffffff
  card.text  ->  color.text  ->  gray.900  ->  #111827

  button.bg resuelve a: #2563eb

Lee cada línea como un viaje por las tres capas. button.bg (capa 3, componente) apunta a color.primary (capa 2, semántico), que apunta a blue.600 (capa 1, primitivo), que es #2563eb (el valor). Cuatro casilleros, tres flechas: del papel al pigmento, exactamente como el pintor. Las otras dos líneas son el mismo viaje para el fondo y el texto de la Card.

Fíjate en lo que resolveToken devuelve: solo el valor final, #2563eb. La función no le importa cuántos eslabones haya en el medio; sigue las flechas hasta topar con un primitivo y devuelve su valor. Esa es toda la mecánica de la capa de tokens: empezar en un token y seguir referencias hasta un valor crudo. Si mañana insertaras una capa más en el medio, resolveToken la recorrería igual, sin cambiar una línea, porque su regla es "sigue apuntando hasta que topes con un valor".

Y fíjate en por qué tres capas y no una. Imagina que la marca cambia de azul a verde. Con esta estructura, cambias un primitivo (blue.600 → green.600) o rediriges un semántico (color.primary → green.600), y button.bg —que nunca supo qué color era— resuelve al nuevo valor solo. El botón no se toca; el token de componente no se toca; solo cambió el eslabón de abajo, y la cadena entera se actualizó. Si button.bg apuntara directo al primitivo blue.600, habrías atado la pieza al valor crudo y perderías la capa que da sentido —volverías al problema de nombrar por valor de la lección 5—. Las tres capas existen para que cada cosa se cambie por su cuenta: el pigmento sin repintar, el rol sin tocar las piezas, la pieza sin afectar el rol global.

Una pregunta para dejarte pensando hacia la lección 6: en el objeto semantics, color.primary tiene { light: 'blue.600', dark: 'blue.400' }. ¿Qué crees que pasa si llamas resolveToken('button.bg', 'dark') en vez de 'light'? (La cadena sería la misma —button.bg → color.primary—, pero en color.primary el tema dark apunta a blue.400 en vez de blue.600, así que el valor final sería #60a5fa. El mismo token de componente, distinto valor, solo porque el semántico apunta a otro primitivo según el tema. Ese es todo el secreto del theming.)

Errores comunes

Atar los componentes directo a los primitivos, saltándose la capa semántica. Qué pasa: se define button.bg → blue.600, sin pasar por color.primary. Por qué pasa: es un eslabón menos, parece más simple y directo. Cómo detectarlo: tus tokens de componente apuntan a nombres de color crudo (blue.600, gray.900) en vez de a roles (color.primary, color.surface). Cómo corregirlo: la capa semántica es la que da sentido y la que hace posible el theming. Si button.bg → blue.600, entonces el botón "sabe" que es azul —y cuando la marca cambie a verde, o cuando el tema oscuro necesite otro azul, tendrás que tocar cada token de componente uno por uno—. Con button.bg → color.primary, el botón solo sabe que usa "el color de marca", y todo el theming y todo el rebranding pasan en un lugar: el semántico. Los tokens de componente apuntan a semánticos, siempre; los semánticos son los únicos que apuntan a primitivos.

Meter valores crudos en la capa semántica. Qué pasa: se define color.primary: '#2563eb' directo, en vez de color.primary → blue.600. Por qué pasa: se confunde "el semántico tiene un valor" con "el semántico es un valor". Cómo detectarlo: tu capa semántica tiene hex escritos, no referencias a primitivos. Cómo corregirlo: un semántico apunta, no contiene. color.primary no es #2563eb; es "lo que valga blue.600". La diferencia importa cuando tienes varios semánticos apuntando al mismo primitivo (por ejemplo color.primary y color.link ambos a blue.600): si el primitivo tiene el valor en un solo lugar, ajustas el azul una vez y los dos roles se mueven; si cada semántico tiene su hex copiado, vuelve el drift dentro de tu propia paleta. El valor crudo vive solo en la capa primitiva.

Crear un primitivo por cada rol (o al revés). Qué pasa: se define un primitivo primary.600 "para el color primario" —mezclando el nivel del valor con el nivel del rol—. Por qué pasa: al principio cuesta ver que un primitivo se nombra por lo que es y un semántico por lo que hace. Cómo detectarlo: tienes primitivos con nombres de rol (primary.600, danger.500) en vez de nombres de familia neutra (blue.600, red.500). Cómo corregirlo: el primitivo se nombra por su familia de color, sin rol: blue.600, red.500, gray.900. El rol vive en el semántico: color.primary → blue.600, color.danger → red.500. Si nombras el primitivo por su rol, ataste el valor a un uso y perdiste la neutralidad que te deja, por ejemplo, hacer que dos roles distintos compartan el mismo primitivo. Primitivo = qué es; semántico = para qué sirve.

Ejercicios

Ejercicio 1 — Clasifica cada token en su capa. Para cada token, di si es primitivo, semántico o de componente, y a qué debería apuntar (si aplica):

  • (a) gray.50 = #f9fafb
  • (b) color.surface
  • (c) searchbar.bg
  • (d) blue.400 = #60a5fa
Ver solución
  • (a) Primitivo. Es un nombre (gray.50) para un valor crudo (#f9fafb), nombrado por su familia y paso, sin rol. No apunta a nada: es el valor. Capa 1.
  • (b) Semántico. Es un rol de diseño ("el color de las superficies/fondos"). Debe apuntar a un primitivo —por ejemplo color.surface → white en claro—. Capa 2.
  • (c) De componente. Ata una pieza concreta (la SearchBar) a un rol. Debe apuntar a un semántico —por ejemplo searchbar.bg → color.surface o → color.muted—, nunca directo a un primitivo. Capa 3.
  • (d) Primitivo. Nombre (blue.400) para un valor crudo (#60a5fa), por familia y paso. Capa 1. (Suele ser el azul que un semántico usa en tema oscuro; la lección 6.)

La pregunta guía: ¿es un valor crudo con nombre neutro? primitivo. ¿es un rol que apunta a un primitivo? semántico. ¿es una pieza atada a un rol? de componente.

Ejercicio 2 — Traza la cadena y el valor. Sin correr nada, escribe la cadena completa que resolveChain('card.bg', 'light') produciría con el set del ejemplo, y di qué devolvería resolveToken('card.bg', 'light').

Ver solución

La cadena, siguiendo las flechas del set:

card.bg  ->  color.surface  ->  white  ->  #ffffff
  • card.bg está en component, apunta a color.surface.
  • color.surface está en semantics, en tema light apunta a white.
  • white está en primitives, vale #ffffff —fin de la cadena—.

resolveToken('card.bg', 'light') devolvería solo el valor final: #ffffff. resolveChain muestra el viaje entero; resolveToken devuelve solo el destino.

Ejercicio 3 — Rediseña la cadena tras un rebranding. La marca de Mercado cambia de azul a verde. Ya existe el primitivo green.600 = #16a34a. Quieres que button.bg (y todo lo que use el color de marca) pase a verde en tema claro, tocando un solo token. ¿Qué token cambias, cómo, y por qué eso basta? ¿Qué pasaría si button.bg apuntara directo a blue.600?

Ver solución

Cambias un solo token: el semántico color.primary, redirigiéndolo del primitivo azul al verde en tema claro:

// antes
'color.primary': { light: 'blue.600', dark: 'blue.400' },
// despues
'color.primary': { light: 'green.600', dark: 'blue.400' },

Con eso basta porque button.bg → color.primary, y color.primary ahora apunta a green.600 = #16a34a. La próxima vez que resolveToken('button.bg', 'light') corra, seguirá la cadena button.bg → color.primary → green.600 → #16a34a y devolverá verde. No tocaste button.bg, ni el botón, ni ninguna otra pieza; solo redirigiste el rol. Y cualquier otro token que use color.primary (un link.color, un badge.bg) se vuelve verde con el mismo cambio.

Si button.bg apuntara directo a blue.600, ese único cambio no bastaría: tendrías que buscar cada token de componente que apunte a blue.600 y redirigirlo a mano —el button.bg, el link.color, el badge.bg, uno por uno—. Habrías perdido la capa que centraliza "el color de marca". Esta es, en pequeño, toda la razón de existir de la capa semántica: un rebranding es un cambio de un eslabón, no de N piezas.

Resumen y siguiente paso

En esta lección instalaste el esqueleto de todo el módulo: los tokens se estructuran en tres capas —primitivos, semánticos y de componente— y cada capa apunta a la de abajo. Con la paleta del pintor viste los tres niveles con precisión: el pigmento crudo (blue.600, primitivo), la etiqueta por su rol que apunta a un pigmento (color.primary, semántico), y la pieza del cuadro atada a un rol (button.bg, de componente). Escribiste resolveToken, que recorre la cadena button.bg → color.primary → blue.600 → #2563eb hasta el valor final, y viste por qué tres capas: para cambiar el pigmento sin repintar, el rol sin tocar las piezas, y la pieza sin afectar el rol global —cada cosa se cambia por su cuenta, y un rebranding es un cambio de un solo eslabón—.

Antes de avanzar deberías poder: nombrar las tres capas y qué contiene cada una; clasificar un token cualquiera en su capa; trazar la cadena de un token de componente hasta su valor; y explicar por qué un componente debe apuntar a un semántico y no a un primitivo.

La lección 4 baja esta estructura al navegador: las CSS custom properties. Hasta aquí las tres capas vivieron en un objeto de JavaScript, pero en una app real los tokens semánticos se escriben como --color-primary: #2563eb en el CSS, y las piezas los leen con var(--color-primary). Vas a ver cómo la cadena de tres capas se refleja en CSS —los primitivos y semánticos como custom properties, las piezas leyéndolas— y a ejecutar un emisor que genera el bloque :root real a partir del set de tokens. Es el puente entre el modelo y el código que de verdad corre en el navegador.

Recursos