@PI/UI — DESIGN SYSTEM
Cómo crear
temas
Guía práctica del sistema de theming: qué tokens existen, cómo se aplican, y los pasos para dar de alta un tema nuevo sin tocar ni un selector de componente.

Basada en el estado real de libs/ui/src/lib/styles: los cuatro temas ya presentes en el repo (Default, Alternative, Minimal, Gradient) sirven de ejemplo en todo el documento. Incluye los tokens de overlays (modal, notificación, tooltip, accordion) añadidos tras la primera versión de esta guía.
1 Arquitectura: shell + tokens
2 El contrato de tokens
3 Crear un tema, paso a paso
4 Los cuatro temas del repo
5 Caso de estudio: anillo de foco en degradado
6 Modo oscuro y temas
7 Checklist

1Arquitectura: shell + tokens

Dos piezas, ninguna es un selector de componente.

Un tema en @pi/ui no es un solo fichero que reescriba .pi-button, .pi-card o cualquier otro selector de componente. Es, exclusivamente, un bloque que redefine variables CSS (custom properties) bajo una clase contenedora. Los componentes ya están escritos para leer esas variables — cambiarlas es todo lo que hace falta.

La clase contenedora: .pi-shell

Todo tema se aplica añadiendo dos clases a un elemento raíz (normalmente <body>, porque los overlays de Material — diálogos, menús, snackbars — cuelgan de ahí y también deben heredar el tema):

<body class="pi-shell pi-shell__<nombre-del-tema>">

.pi-shell por sí sola ya aplica el contrato completo de tokens con sus valores por defecto (ver themes/_default.scss). .pi-shell__<nombre> se aplica a la vez, no en su lugar, y sobreescribe solo los tokens que ese tema quiere cambiar. Como ambas clases tienen la misma especificidad, gana la que se declaró más tarde en el CSS compilado — por eso el orden de @forward en themes/index.scss importa (ver §3).

El helper shellTheming() (en libs/ui/src/lib/styles/configs.ts) construye ese string por ti: shellTheming('minimal') devuelve 'pi-shell pi-shell__minimal'. ShellClass ya expone uno por tema — no hace falta escribir el string a mano.

Las dos familias de variables

PrefijoQué controlaEjemplo
--pi-color-* Paleta de marca: primary, accent, highlighted, warn, success, grey, cada uno con escalones (-10 a -900). --pi-color-primary, --pi-color-primary-900
--pi-theme-* Forma y comportamiento: radios, sombras, grosor de borde, peso/transformación de texto, rellenos de botón. --pi-theme-button-radius, --pi-theme-card-shadow

Un tema puede tocar una familia, la otra, o ambas. _alternative.scss cambia las dos (repaleta primary/accent/highlighted y la forma). _minimal.scss y _gradient.scss solo tocan --pi-theme-* — la paleta de marca se queda igual a propósito.

El puente con Angular Material

Los componentes nativos de Material (slider, checkbox, radio…) no leen --pi-color-*: pintan con sus propios roles --mat-sys-*, que genera mat.theme(). El puente se declara una sola vez, en _default.scss, por indirección:

--mat-sys-primary: var(--pi-color-primary);
--mat-sys-primary-container: var(--pi-color-primary-100);
/* … */

Como es una indirección, cualquier tema que redefina --pi-color-primary arrastra automáticamente a Material sin tener que repetir el bloque del puente. Si tu tema no toca la paleta (como Minimal o Gradient), no necesitas tocar esto en absoluto.

Límite conocido: el puente cubre color, no forma. El radio/sombra de checkbox, radio, slider o mat-select vienen de tokens --mdc-* que Angular Material fija en tiempo de compilación (no son variables de runtime reasignables desde un tema). Aplanarlos también requeriría overrides por componente — fuera del alcance de este contrato.

2El contrato de tokens

Todo lo que un tema puede redefinir, con su valor en el tema por defecto y dónde se consume.

Botones

TokenPor defectoSe usa en
--pi-theme-button-heightvar(--pi-spacing-xxx-large) (2.5rem)pi-button/_pi-button-theme.scss
--pi-theme-button-radiusvar(--pi-border-radius-medium)pi-button/_pi-button.scss, dynamic-menu.component.scss
--pi-theme-button-font-weight600pi-button-theme.scss
--pi-theme-button-letter-spacingnormal
--pi-theme-button-text-transformnone
--pi-theme-button-shadownone
--pi-theme-button-transitionbackground-color .16s ease-inpi-button-theme.scss, stepper.component.scss, file-upload.component.scss, dynamic-menu.component.scss
--pi-theme-button-fill-{primary,accent,warn,success,highlighted}none (manda el background-color sólido)_pi-button-theme.scss

Campos de formulario

TokenPor defectoSe usa en
--pi-theme-field-radius0.875remvendors/_mat-form-field.scss, file-upload.component.scss, _mat-datepicker.scss, _mat-select.scss
--pi-theme-field-border-width1px
--pi-theme-field-border-colorvar(--pi-color-grey-200)
--pi-theme-field-border-color-hovervar(--pi-color-grey-300)
--pi-theme-field-border-color-focusvar(--pi-color-primary)
--pi-theme-field-surfacevar(--pi-color-white)
--pi-theme-field-label-colorvar(--pi-color-grey-300)
--pi-theme-field-label-transform / -letter-spacing / -weightsin declarar → hereda el valor original del componente_mat-form-field.scss
--pi-theme-field-focus-gradientsin declarar → none (opt-in, ver §5)_mat-form-field.scss

Superficies

TokenPor defectoSe usa en
--pi-theme-card-radiusvar(--pi-border-radius-large)components/_pi-card.scss, skeleton.component.scss
--pi-theme-card-shadowvar(--pi-box-shadow-panel)_pi-card.scss
--pi-theme-panel-radiusvar(--pi-border-radius-large)panel.component.scss, _mat-select.scss, _mat-datepicker.scss, dynamic-menu.component.scss
--pi-theme-chip-radiussin declarar → radio original del chipchip/styles/_size.scss (×3)
Fíjate en el patrón var(--pi-theme-x, <valor-original>) que usan varios consumidores (chip, panel, card, overlays). Eso significa que no es obligatorio declarar todos los tokens en un tema nuevo: los que omitas caen al valor que ya tenía el componente antes de que existiera el sistema de theming. Solo declara lo que quieras cambiar.

Overlays (modal, notificación, tooltip, accordion)

Grupo añadido después de la primera versión de este contrato. Ningún tema salvo Minimal los redefine todavía: Default, Alternative y Gradient usan siempre el segundo argumento de var(), que reproduce exactamente la forma que tenía cada overlay antes de tokenizar.

TokenPor defecto (fallback)Se usa en
--pi-theme-modal-radiussin declarar → var(--pi-border-radius-medium) (var(--pi-border-radius-large) en .pi-modal__panel--full)vendors/_mat-dialog.scss (×2)
--pi-theme-notification-radiussin declarar → var(--pi-border-radius-large)snack-bar.component.scss, _mat-snackbar.scss (×2)
--pi-theme-notification-shadowsin declarar → var(--pi-box-shadow)_mat-snackbar.scss
--pi-theme-tooltip-radiussin declarar → var(--pi-border-radius-medium)_mat-tooltip.scss
--pi-theme-tooltip-shadowsin declarar → var(--pi-box-shadow-panel-double)_mat-tooltip.scss
--pi-theme-accordion-radiussin declarar → var(--pi-border-radius-large)accordion.component.scss, _horizontal-accordion.scss, _vertical-accordion.scss (×6, incluye un calc())
Minimal declara los seis en su mixin (_minimal.scss): aplana todos los radios a --pi-border-radius-xx-small y apaga las dos sombras (none) — mismo criterio que ya aplicaba a botón, campo y card, extendido al resto de superficies flotantes.

3Crear un tema, paso a paso

Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio brutalist.

1
Crea libs/ui/src/lib/styles/themes/_brutalist.scss

Un mixin con los tokens que cambian, más una clase que lo aplica:

@mixin brutalistThemeTokens {
  --pi-theme-button-radius: 0;
  --pi-theme-button-shadow: 4px 4px 0 var(--pi-color-black);
  --pi-theme-card-radius: 0;
  --pi-theme-card-shadow: none;
}

.pi-shell__brutalist {
  @include brutalistThemeTokens;
}
2
Regístralo en themes/index.scss

Añade el @forward/@use al final de la lista — el orden es el orden de prioridad en el CSS compilado:

@forward 'default';
@forward 'alternative';
@forward 'minimal';
@forward 'gradient';
@forward 'brutalist'; // ← nuevo, al final

@use 'default' as *;
@use 'alternative' as *;
@use 'minimal' as *;
@use 'gradient' as *;
@use 'brutalist' as *; // ← nuevo, al final
3
Añade la entrada en ShellClass (libs/ui/src/lib/styles/configs.ts)
export const ShellClass = {
  Default: shellTheming('default'),
  Alternative: shellTheming('alternative'),
  Minimal: shellTheming('minimal'),
  Gradient: shellTheming('gradient'),
  /* Bordes duros, sombra dura, sin medias tintas. */
  Brutalist: shellTheming('brutalist'),
};
4
Aplícalo en la app consumidora

En el playground esto ya está resuelto con un signal + effect (ver apps/playground/src/app/app.ts); en cualquier otra app basta con:

import { ShellClass } from '@pi/ui';

document.body.className = ShellClass.Brutalist;
No hay quinto paso. No se toca ningún .component.scss, ningún selector, ningún componente TypeScript. Si necesitas cambiar algo que no está en el contrato de tokens (§2), esa es la señal de que hace falta añadir un token nuevo al contrato — y entonces sí tocas el .scss del componente afectado, pero solo una vez, para que quede disponible para todos los temas futuros (así se añadió --pi-theme-field-focus-gradient, ver §5).

4Los cuatro temas del repo

Cuatro implementaciones reales del mismo contrato, en libs/ui/src/lib/styles/themes/.

Default

Rellenos planos y sólidos, controles compactos. Declara el contrato completo — el resto de temas parten de aquí.

Alternative

Aspecto de marketing: repaleta primary→violeta, accent→turquesa, botones en pastilla con degradado y sombra, campos con superficie tintada.

Minimal

Esquinas casi rectas, sin sombra (separa por borde fino en vez de elevación), sin degradados ni versalitas. Ahora también aplana los overlays — modal, notificación, tooltip y accordion comparten el mismo radio recto. Misma paleta que Default.

Gradient

Igual que Default en forma, pero con degradado en el relleno de los botones y en el anillo de foco de los campos.

Los cuatro comparten exactamente los mismos selectores de componente — la prueba de que el contrato de tokens funciona es que ninguno necesitó tocar un .component.ts ni un .component.html.

5Caso de estudio: anillo de foco en degradado

Qué hacer cuando el efecto que quieres no lo cubre ningún token existente.

El tema Gradient quería un borde en degradado al enfocar un campo. El problema: el borde de un <mat-form-field appearance="outline"> no es un border normal — es tres <div> (leading, notch, trailing) cada uno con su propio borde parcial, para dejar hueco a la etiqueta flotante. Un background en degradado ahí se vería cortado en cada unión.

La solución: un anillo aparte, no el borde del notch

En vez de pelear con los tres fragmentos, se añade un ::after sobre .mat-mdc-text-field-wrapper (la caja completa, sin partir) que pinta solo el borde gracias a mask-composite: se enmascara la copia padding-box (el interior) fuera de la copia border-box (el rectángulo completo), dejando visible únicamente el anillo:

libs/ui/src/lib/styles/common/vendors/_mat-form-field.scss
.mat-mdc-text-field-wrapper { position: relative; }

&.mat-focused .mat-mdc-text-field-wrapper::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: var(--pi-theme-field-radius);
  padding: var(--pi-theme-field-border-width, 1px);
  background: var(--pi-theme-field-focus-gradient, none);
  -webkit-mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}

El token nuevo, --pi-theme-field-focus-gradient, se añadió al contrato compartido (consumido con fallback none) — así cualquier tema puede optar por él sin que los demás se vean afectados. Gradient además pone --pi-theme-field-border-color-focus: transparent para apagar el borde sólido del notch; si no, se vería el borde plano por debajo del anillo.

Sin degradado
focus-gradient: none
Con degradado
(anillo vía mask-composite)
Regla general: cuando el efecto que buscas no cabe en una simple reasignación de variable, la solución sigue siendo "un token opt-in con fallback inerte" + "una técnica CSS aislada en el fichero compartido del componente" — nunca un selector nuevo dentro del propio fichero del tema. El fichero de tema solo declara valores.

6Modo oscuro y temas

El modo oscuro del playground es ortogonal a los temas — conviven, no se solapan.

@pi/ui no trae un tema oscuro propio. El playground activa uno "best-effort" añadiendo la clase dark-mode al <html> (no al <body>, que es donde vive .pi-shell), y ahí reasigna solo los neutros (--pi-color-white, --pi-color-grey-*, --pi-color-black) — las paletas de marca (primary, accent…) se dejan igual:

apps/playground/src/app/styles.scss
html.dark-mode {
  --pi-color-white: #1c1f26;
  --pi-color-grey-100: #2a2e38;
  --pi-color-black: #f1f2f4;
  /* … solo neutros, la marca no cambia … */
}

Como es una clase distinta en un elemento distinto, cualquier combinación (tema × modo oscuro) funciona sin código extra — son dos cascadas independientes que solo coinciden en que ambas reasignan variables CSS. La única excepción real es cuando un tema usa un escalón de marca como si fuera superficie neutra (por ejemplo, --pi-color-primary-25 como fondo de campo en Alternative): ese caso puntual sí necesita un ajuste en modo oscuro, ya presente en styles.scss bajo html.dark-mode .pi-shell__alternative.

7Checklist

Antes de dar el tema por terminado

Evita