Files
pireon-ui/libs/ui/TOKENS.md
2026-08-06 20:51:46 +02:00

314 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <body>
themeService.toggleDarkMode(); // pinta/quita dark-mode en <html>
```
`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/_<nombre>.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-<color>`.
### 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-<superficie>-<propiedad>, <valor-original>)`. 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/_<nombre>.scss` con un mixin
`<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si
aplica, `--pi-color-*`) que cambian respecto a Default.
2. Aplícalo a `.pi-shell__<nombre>`.
3. Impórtalo desde `themes/index.scss`.
4. Añade la entrada a `ShellClass` en `styles/configs.ts`.
5. Añade `'<nombre>'` 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.