# Tokens de `@pi/ui` Todo el aspecto visual de la librería (color, forma, sombra, tipografía de controles) se resuelve en **custom properties de CSS**, nunca en valores hardcodeados dentro de un componente. Hay dos familias con propósitos distintos, más un puñado de primitivas base: | Familia | Dónde vive | Qué representa | Cuántos temas la redefinen | | --------------- | ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------- | | `--pi-color-*` | `:root` (`_pi-color-variables.scss`) | Paleta de marca (primary/accent/highlighted/warn/success/grey/white/black/background) | Todos excepto Minimal | | `--pi-theme-*` | `.pi-shell` (`themes/_*.scss`) | Forma y comportamiento por superficie: botón, campo, card, panel, chip, overlay | Todos | | Primitivas base | `:root` (`_pi-global-variables.scss`, `_pi-font-sizes-variables.scss`, `_pi-public-variables.scss`) | Espaciado, radios, sombras, tamaños de fuente, animaciones — sin tematizar | Ninguno | Los componentes de `common/` (botón, campo, card, panel, chip, modal, snackbar, tooltip, accordion…) **no leen la paleta ni la forma directamente**: consumen `--pi-theme-*`, y son los ficheros de `styles/themes/` los que deciden qué valor tiene cada token. Así, un tema nuevo no toca un solo selector de componente — solo redefine tokens (ver `_alternative.scss`, `_minimal.scss`, `_gradient.scss` como ejemplo). ## Cómo se aplica un tema La forma recomendada es `PiThemeService` — inyectable, con persistencia en `localStorage` y sin tener que tocar el DOM a mano: ```ts import { inject } from '@angular/core'; import { PiThemeService } from '@pi/ui'; const themeService = inject(PiThemeService); themeService.setTheme('alternative'); // pinta pi-shell pi-shell__alternative en themeService.toggleDarkMode(); // pinta/quita dark-mode en ``` `theme`/`darkMode` son señales normales: también se pueden mutar con un `[(value)]`/`[(checked)]` dos vías directo sobre la señal (ver el uso real en `apps/playground/src/app/app.ts`/`app.html`). Por debajo, cada cambio dispara un `effect()` que aplica la clase y persiste — cualquier camino de escritura queda cubierto por igual, no solo los setters. Si no quieres el servicio (p. ej. fuera de un contexto de inyección), el mecanismo de bajo nivel sigue disponible: ```ts import { ShellClass } from '@pi/ui'; document.body.className = ShellClass.Alternative; // 'pi-shell pi-shell__alternative' ``` `ShellClass` expone `Default`, `Alternative`, `Minimal` y `Gradient` (`libs/ui/src/lib/styles/configs.ts`). Cada uno corresponde a un mixin en `libs/ui/src/lib/styles/themes/_.scss`, incluido desde `themes/index.scss`. Dentro de un shell se puede forzar el aspecto contrario en un subárbol con `.is-private`/`.is-public` (p. ej. el input `context` de `pi-card`). ### El patrón de fallback Un token puede consumirse de dos formas, según si el tema por defecto lo declara o no: ```scss // El tema Default declara este token → todos los temas lo tienen. border-radius: var(--pi-theme-card-radius); // El tema Default NO lo declara → el segundo argumento de var() reproduce // el valor original hardcodeado. Solo Minimal (u otro tema futuro) lo pisa. border-radius: var(--pi-theme-chip-radius, #{$chip-border-radius}); ``` Esto es intencional: los tokens que **todos** los temas existentes necesitan personalizar (radio/altura/tipografía de botón, forma de campo) se declaran en `_default.scss`. Los que solo un tema concreto toca (radio de overlays en Minimal, degradado de foco en Gradient) se dejan sin declarar en Default —así su valor por defecto es exactamente el que tenía el componente antes de tokenizar, byte a byte, y ningún tema existente cambia de aspecto sin querer. ## `--pi-theme-*`: tabla completa **D** = Default · **A** = Alternative · **M** = Minimal · **G** = Gradient. Una celda vacía significa "no lo redefine, hereda el valor de la fila D (o el fallback de `var()` si D tampoco lo declara, marcado como *(fallback)*)". ### Botón (`pi-button/_pi-button.scss`, `_pi-button-theme.scss`, `menu/dynamic-menu`) | Token | D | A | M | G | | --- | --- | --- | --- | --- | | `--pi-theme-button-height` | `var(--pi-spacing-xxx-large)` (2.5rem) | `2.875rem` | `2.5rem` | — | | `--pi-theme-button-radius` | `var(--pi-border-radius-medium)` | `999px` (píldora) | `var(--pi-border-radius-xx-small)` | — | | `--pi-theme-button-font-weight` | `600` | `700` | `500` | — | | `--pi-theme-button-letter-spacing` | `normal` | `0.04em` | `normal` | — | | `--pi-theme-button-text-transform` | `none` | `uppercase` | `none` | — | | `--pi-theme-button-shadow` | `none` | `0 6px 16px -6px rgba(106,63,212,.55)` | `none` | — | | `--pi-theme-button-transition` | `background-color .16s ease-in` | `all .2s ease-out` | `background-color .12s ease-out, border-color .12s ease-out` | — | | `--pi-theme-button-fill-primary` | `none` (relleno plano) | degradado `primary-900→primary-400` | `none` | degradado `primary-400→primary-900` | | `--pi-theme-button-fill-accent` | `none` | degradado `accent-900→accent-400` | `none` | degradado `accent-400→accent-900` | | `--pi-theme-button-fill-warn` | `none` | — | `none` | — | | `--pi-theme-button-fill-success` | `none` | — | `none` | — | | `--pi-theme-button-fill-highlighted` | `none` | degradado `highlighted→highlighted-300` | `none` | degradado `highlighted-300→highlighted` | `--pi-theme-button-fill-#{$color}` se interpola por nombre de color en `_pi-button-theme.scss:47` (`background-image: var(--pi-theme-button-fill-#{$color}, none)`), así que un tema nuevo que quiera degradado en un color que no sea primary/accent/highlighted solo tiene que declarar `--pi-theme-button-fill-`. ### Campos de formulario (`_mat-form-field.scss`, `file-upload`, `_mat-datepicker.scss`, `_mat-select.scss`) | Token | D | A | M | G | | --- | --- | --- | --- | --- | | `--pi-theme-field-radius` | `0.875rem` | `999px` | `var(--pi-border-radius-xx-small)` | — | | `--pi-theme-field-border-width` | `1px` | `2px` | `1px` | — | | `--pi-theme-field-border-color` | `var(--pi-color-grey-200)` | `var(--pi-color-primary-100)` | `var(--pi-color-grey-200)` | — | | `--pi-theme-field-border-color-hover` | `var(--pi-color-grey-300)` | `var(--pi-color-primary-300)` | `var(--pi-color-grey-400)` | — | | `--pi-theme-field-border-color-focus` | `var(--pi-color-primary)` | `var(--pi-color-primary)` | `var(--pi-color-black)` | `transparent` (deja ver el degradado) | | `--pi-theme-field-surface` | `var(--pi-color-white)` | `var(--pi-color-primary-25)` | `var(--pi-color-white)` | — | | `--pi-theme-field-label-color` | `var(--pi-color-grey-300)` | `var(--pi-color-primary-900)` | `var(--pi-color-grey-400)` | — | | `--pi-theme-field-label-transform` *(fallback: `none`)* | — | `uppercase` | `none` | — | | `--pi-theme-field-label-letter-spacing` *(fallback: `normal`)* | — | `0.08em` | `normal` | — | | `--pi-theme-field-label-weight` *(fallback: `inherit`)* | — | `700` | `500` | — | | `--pi-theme-field-focus-gradient` *(fallback: `none`)* | — | — | — | `linear-gradient(120deg, primary, accent, highlighted)` | ### Superficies: card / panel / chip | Token | D | A | M | G | Consumidores | | --- | --- | --- | --- | --- | --- | | `--pi-theme-card-radius` | `var(--pi-border-radius-large)` | `1.5rem` | `var(--pi-border-radius-xx-small)` | — | `_pi-card.scss`, `skeleton.component.scss` | | `--pi-theme-card-shadow` | `var(--pi-box-shadow-panel)` | `0 10px 30px -12px rgba(59,13,107,.28)` | `none` | — | `_pi-card.scss` | | `--pi-theme-panel-radius` | `var(--pi-border-radius-large)` | `1.5rem` | `var(--pi-border-radius-xx-small)` | — | `panel.component.scss`, `_mat-datepicker.scss`, `_mat-select.scss`, `dynamic-menu` | | `--pi-theme-chip-radius` *(fallback: radio original por tamaño)* | — | `0.25rem` | `0` | — | `chip/styles/_size.scss` (×3, por tamaño y por affix) | ### Overlays: modal / notification / tooltip / accordion Ningún tema salvo Minimal los redefine — Default/Alternative/Gradient usan siempre el *fallback* de `var()` (el valor que tenían antes de tokenizar). | Token | Fallback (D/A/G) | M | Consumidores | | --- | --- | --- | --- | | `--pi-theme-modal-radius` | `var(--pi-border-radius-medium)` (panel completo) / `var(--pi-border-radius-large)` (`.pi-modal__panel--full`) | `var(--pi-border-radius-xx-small)` | `_mat-dialog.scss` (×2) | | `--pi-theme-notification-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `snack-bar.component.scss`, `_mat-snackbar.scss` (×2) | | `--pi-theme-notification-shadow` | `var(--pi-box-shadow)` | `none` | `_mat-snackbar.scss` | | `--pi-theme-tooltip-radius` | `var(--pi-border-radius-medium)` | `var(--pi-border-radius-xx-small)` | `_mat-tooltip.scss` | | `--pi-theme-tooltip-shadow` | `var(--pi-box-shadow-panel-double)` | `none` | `_mat-tooltip.scss` | | `--pi-theme-accordion-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `accordion.component.scss`, `_horizontal-accordion.scss`, `_vertical-accordion.scss` (×6, incluye un `calc(... - 0.0625rem)`) | | `--pi-theme-overlay-divider-color` | `var(--pi-color-grey-100)` | — | `drawer.component.scss`, `hub.component.scss` (borde bajo la cabecera) | | `--pi-theme-hub-grabber-color` | `var(--pi-color-grey-100)` | — | `hub.component.scss` (el asa superior) | ### Puente con Angular Material (`--mat-sys-*`) Los componentes de Material (slider, checkbox, radio, progress…) no leen `--pi-color-*`: pintan con sus propios roles `--mat-sys-*`, generados por `mat.theme()` a partir de una paleta separada. `_default.scss` reengancha una cosa con otra **una sola vez**, por indirección: ```scss --mat-sys-primary: var(--pi-color-primary); --mat-sys-on-primary: var(--pi-color-white); --mat-sys-primary-container: var(--pi-color-primary-100); --mat-sys-on-primary-container: var(--pi-color-primary-900); --mat-sys-secondary: var(--pi-color-accent); --mat-sys-on-secondary: var(--pi-color-white); --mat-sys-secondary-container: var(--pi-color-accent-100); --mat-sys-on-secondary-container: var(--pi-color-accent-900); --mat-sys-tertiary: var(--pi-color-highlighted); --mat-sys-on-tertiary: var(--pi-color-white); --mat-sys-error: var(--pi-color-warn); --mat-sys-on-error: var(--pi-color-white); ``` Como es indirección (`var(--pi-color-*)`, no un valor literal), un tema que redefina `--pi-color-primary` etc. (como Alternative) tiñe también los controles de Material sin tener que repetir este bloque. ## `--pi-color-*`: paleta de marca Declarada una vez en `:root` (`_pi-color-variables.scss`) con los valores Default, y redeclarada completa dentro de `.pi-shell__alternative` (Minimal y Gradient **no** tocan la paleta — ver comentario en `_minimal.scss`: "minimalista aquí es una cuestión de forma, no de color"). Se consume **directamente por nombre** (no hay indirección `--pi-theme-*` de por medio) en ~100 ficheros de la librería: bordes, fondos, texto, iconos, estados de badge/chip/status, etc. Cada paleta (`primary`, `accent`, `highlighted`, `warn`, `success`) sigue la misma escala de 10 pasos + variante rgba del tono 200: `10 → 25 → 50 → 75 → 100 → 200 → 200-rgba → 300 → 400 → (base) → 900` | Escala | primary (D) | accent (D) | highlighted (D) | warn (D) | success (D) | | --- | --- | --- | --- | --- | --- | | 10 | `#fdfdff` | `#fffdf9` | `#f9fbff` | `#fff9fa` | `#f7fcf9` | | 25 | `#fafbff` | `#fffaed` | `#f2f5ff` | `#ffedf0` | `#e6f6ed` | | 50 | `#f0f4ff` | `#fdf5e5` | `#e9eeff` | `#fcebeb` | `#def5e8` | | 75 | `#eaefff` | `#fae5bf` | `#dae2ff` | `#ffe7ea` | `#c7ebdc` | | 100 | `#d7e2fb` | `#fce0ad` | `#bccdfa` | `#f7cdcd` | `#bbe8d5` | | 200 | `#c3d5ff` | `#f6d495` | `#6482d7` | `#f2acac` | `#83dbb6` | | 200-rgba | `rgba(196,213,255,.15)` | `rgba(252,224,173,.15)` | `rgba(168,193,255,.15)` | `rgba(237,129,129,.15)` | *(no tiene)* | | 300 | `#a3c0ff` | `#f2c26a` | `#436ac1` | `#ec8b8b` | `#60c99d` | | 400 | `#8cacf2` | `#f6bc63` | `#1d3f8a` | `#e87272` | `#3ebe88` | | base | `#799ef0` | `#eda82a` | `#0d2a6b` | `#e45959` | `#1cb273` | | 900 | `#5772d4` | `#d78f0f` | `#030f3c` | `#c13737` | `#009758` | **Neutros y utilidad**, sin escala de tema (no cambian entre Default/Alternative): | Token | Valor | Uso | | --- | --- | --- | | `--pi-color-white` | `#ffffff` | — | | `--pi-color-white-900` | `#f4f4f4` | — | | `--pi-color-white-rgba-03/05/06/09` | blanco a 33/50/66/88% opacidad | overlays, disabled | | `--pi-color-black-200` | `#323748` | — | | `--pi-color-black` | `#1a1a1a` | texto, foco (Minimal) | | `--pi-color-grey-10…900` | escala de 10 pasos, `#fcfcfd → #3d3d3d` | bordes, texto secundario | | `--pi-color-background-300/400/500` | `#aeaeb6` / `#fcfcfc` / `#f7f7f7` | fondos de página | En `_alternative.scss` la paleta completa (`primary`, `accent`, `highlighted`) se redeclara con una gama azul→violeta / turquesa / violeta oscuro, respectivamente. `warn` y `success` se dejan intactos a propósito en todos los temas: son colores semánticos y cambiarlos perjudicaría la lectura del estado. > Nota: `_pi-color-variables.scss` declara `--pi-color-grey-200` dos veces > (líneas 328-329) y `--pi-color-white`/`--pi-color-background-400` con el > mismo patrón en otro punto del fichero — inofensivo (la segunda declaración > gana y tiene el mismo valor) pero es limpieza pendiente si se retoca el > fichero. ## Primitivas base (sin tematizar) Estas variables no cambian entre temas — son la escala de espaciado/forma/sombra de toda la librería, y los tokens `--pi-theme-*` casi siempre se expresan en función de ellas (`var(--pi-border-radius-large)`, `var(--pi-spacing-xxx-large)`…). | Familia | Tokens | Rango | | --- | --- | --- | | Espaciado | `--pi-spacing-xxxxx-small` … `--pi-spacing-xxxx-large` | `0.0625rem` (1px) … `3rem` (48px), 11 pasos | | Radio | `--pi-border-radius-xx-small` … `--pi-border-radius-x-large` | `0.125rem` (2px) … `1.375rem` (22px), 5 pasos | | Sombra | `--pi-box-shadow`, `-hover`, `-double`, `-panel`, `-panel-double`, `-panel-disabled`, `-top`, `-inset` | valores `rgba` fijos, ver `_pi-global-variables.scss` | | Animación | `--pi-animation`, `-fast`, `-slow` | `all .16s/.25s/.4s ease-out` | | Tamaño de fuente | `--pi-font-size-xxx-small` … `-xxx-large` | `0.5rem` (8px) … `2.875rem` (46px), 9 pasos | | Layout (header/footer/navbar/aside/card) | `--pi-height-*`, `--pi-width-*` | ver `_pi-global-variables.scss` | | Landing/marketing | `--pi-public-font-size-*`, `--pi-public-height-*`, `--pi-public-width-*` | ver `_pi-public-variables.scss` | `_pi-gradient-variables.scss` (`$pi-gradient-primary-light`, etc.) son variables **Sass**, no custom properties de CSS — se resuelven en tiempo de compilación y no forman parte del contrato de theming en runtime. ## Cómo tokenizar una superficie nueva 1. En el consumidor, cambia el valor hardcodeado por `var(--pi-theme--, )`. El fallback garantiza que ningún tema existente cambia de aspecto hasta que decida redefinir el token. 2. Si **todos** los temas van a necesitar tocarlo pronto, decláralo también en `defaultThemeTokens` (`_default.scss`) con el valor original — así queda documentado como parte del contrato en vez de vivir solo como fallback disperso en el CSS. 3. Añade el override en el/los temas que lo necesiten (`_alternative.scss`, `_minimal.scss`, `_gradient.scss`). 4. Actualiza este documento. ## Cómo añadir un tema nuevo La forma recomendada es el generador: ```bash nx g @pi/ui:theme mi-marca ``` Crea `libs/ui/src/lib/styles/themes/_mi-marca.scss` (en blanco, con los grupos de tokens más habituales comentados a modo de chuleta), lo registra en `themes/index.scss`, añade la entrada a `ShellClass` en `styles/configs.ts` y extiende `ThemeVariant`/`PiThemeService` — el tema queda seleccionable con `themeService.setTheme('mi-marca')` sin ningún paso manual más. Solo falta descomentar y ajustar los tokens que definen el aspecto, y (si tu app expone un selector de tema) añadirlo a esa lista. Estos son los pasos que da por ti, si prefieres hacerlo a mano o entender qué toca: 1. Crea `libs/ui/src/lib/styles/themes/_.scss` con un mixin `ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si aplica, `--pi-color-*`) que cambian respecto a Default. 2. Aplícalo a `.pi-shell__`. 3. Impórtalo desde `themes/index.scss`. 4. Añade la entrada a `ShellClass` en `styles/configs.ts`. 5. Añade `''` a `ThemeVariant` y una entrada a `shellClassByVariant` en `util/theme/theme.service.ts`. No hace falta tocar ningún componente: si consumen los tokens en vez de valores literales, el tema nuevo se propaga solo. ## Lint de tokens: evitar que un color de marca se filtre sin querer El bug que motivó tokenizar accordion/tab/select-option/drawer/hub esta misma sesión (un `var(--pi-color-*)` a pelo en el componente, así que ningún tema ni el modo oscuro puede redefinir esa superficie) tiene ahora una regla de stylelint que lo detecta automáticamente: `pi/no-raw-color-token` (`tools/stylelint-plugin-pi-theme/`). ```bash nx run ui:lint-styles ``` Marca cualquier `--pi-color-*` que aparezca fuera del *fallback* de un `var(--pi-theme-*, ...)` — es decir, exactamente el patrón contrario al de ["El patrón de fallback"](#el-patrón-de-fallback). Están exentos `styles/themes/**` (ahí `--pi-color-*` es legítimo: son las definiciones) y `styles/common/abstracts/**` (donde se declaran las primitivas de la paleta). **Estado actual: es un target aparte, no está enganchado a `nx lint` ni a CI.** Al escribirse, señala ~500 usos preexistentes repartidos por componentes que esta sesión no llegó a tokenizar — adoptarlo como gate exige ir migrándolos primero (mismo patrón que en "Cómo tokenizar una superficie nueva"). Úsalo para no introducir *más* mientras se migra el resto.