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.
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.
.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).
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.
| Prefijo | Qué controla | Ejemplo |
|---|---|---|
--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.
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.
--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.
Todo lo que un tema puede redefinir, con su valor en el tema por defecto y dónde se consume.
| Token | Por defecto | Se usa en |
|---|---|---|
--pi-theme-button-height | var(--pi-spacing-xxx-large) (2.5rem) | pi-button/_pi-button-theme.scss |
--pi-theme-button-radius | var(--pi-border-radius-medium) | pi-button/_pi-button.scss, dynamic-menu.component.scss |
--pi-theme-button-font-weight | 600 | pi-button-theme.scss |
--pi-theme-button-letter-spacing | normal | |
--pi-theme-button-text-transform | none | |
--pi-theme-button-shadow | none | |
--pi-theme-button-transition | background-color .16s ease-in | pi-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 |
| Token | Por defecto | Se usa en |
|---|---|---|
--pi-theme-field-radius | 0.875rem | vendors/_mat-form-field.scss, file-upload.component.scss, _mat-datepicker.scss, _mat-select.scss |
--pi-theme-field-border-width | 1px | |
--pi-theme-field-border-color | var(--pi-color-grey-200) | |
--pi-theme-field-border-color-hover | var(--pi-color-grey-300) | |
--pi-theme-field-border-color-focus | var(--pi-color-primary) | |
--pi-theme-field-surface | var(--pi-color-white) | |
--pi-theme-field-label-color | var(--pi-color-grey-300) | |
--pi-theme-field-label-transform / -letter-spacing / -weight | sin declarar → hereda el valor original del componente | _mat-form-field.scss |
--pi-theme-field-focus-gradient | sin declarar → none (opt-in, ver §5) | _mat-form-field.scss |
| Token | Por defecto | Se usa en |
|---|---|---|
--pi-theme-card-radius | var(--pi-border-radius-large) | components/_pi-card.scss, skeleton.component.scss |
--pi-theme-card-shadow | var(--pi-box-shadow-panel) | _pi-card.scss |
--pi-theme-panel-radius | var(--pi-border-radius-large) | panel.component.scss, _mat-select.scss, _mat-datepicker.scss, dynamic-menu.component.scss |
--pi-theme-chip-radius | sin declarar → radio original del chip | chip/styles/_size.scss (×3) |
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.
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.
| Token | Por defecto (fallback) | Se usa en |
|---|---|---|
--pi-theme-modal-radius | sin declarar → var(--pi-border-radius-medium) (var(--pi-border-radius-large) en .pi-modal__panel--full) | vendors/_mat-dialog.scss (×2) |
--pi-theme-notification-radius | sin declarar → var(--pi-border-radius-large) | snack-bar.component.scss, _mat-snackbar.scss (×2) |
--pi-theme-notification-shadow | sin declarar → var(--pi-box-shadow) | _mat-snackbar.scss |
--pi-theme-tooltip-radius | sin declarar → var(--pi-border-radius-medium) | _mat-tooltip.scss |
--pi-theme-tooltip-shadow | sin declarar → var(--pi-box-shadow-panel-double) | _mat-tooltip.scss |
--pi-theme-accordion-radius | sin declarar → var(--pi-border-radius-large) | accordion.component.scss, _horizontal-accordion.scss, _vertical-accordion.scss (×6, incluye un calc()) |
--pi-theme-overlay-divider-color | sin declarar → var(--pi-color-grey-100) | drawer.component.scss, hub.component.scss |
--pi-theme-hub-grabber-color | sin declarar → var(--pi-color-grey-100) | hub.component.scss |
_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. Los dos
últimos (drawer/hub) son más recientes y todavía ningún tema los redefine.
Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio brutalist.
nx g @pi/ui:theme brutalist. Crea el fichero del paso 1 (en
blanco, con los grupos de tokens más habituales comentados), hace los
pasos 2 y 3, y además extiende PiThemeService (paso 4) para
que el tema quede seleccionable sin ningún paso manual más. Lo que sigue
es para quien prefiera hacerlo a mano o quiera entender qué toca cada
paso.
libs/ui/src/lib/styles/themes/_brutalist.scssUn 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;
}
themes/index.scssAñ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
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'),
};
Con PiThemeService (ver apps/playground/src/app/app.ts) basta con:
import { inject } from '@angular/core';
import { PiThemeService } from '@pi/ui';
const themeService = inject(PiThemeService);
themeService.setTheme('brutalist');
Si el generador ya extendió ThemeVariant/shellClassByVariant (paso automático 4), no hace falta tocar nada más que este setTheme. Sin el servicio, el mecanismo de bajo nivel sigue funcionando igual:
import { ShellClass } from '@pi/ui';
document.body.className = ShellClass.Brutalist;
.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).
Cuatro implementaciones reales del mismo contrato, en libs/ui/src/lib/styles/themes/.
Rellenos planos y sólidos, controles compactos. Declara el contrato completo — el resto de temas parten de aquí.
Aspecto de marketing: repaleta primary→violeta, accent→turquesa, botones en pastilla con degradado y sombra, campos con superficie tintada.
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.
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.
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.
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:
.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.
focus-gradient: nonemask-composite)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) — hoy vía
PiThemeService.setDarkMode()/toggleDarkMode(),
que hace ese toggle por ti (persistido en localStorage) en
vez de que cada app repita el mismo effect() — y ahí reasigna
solo los
neutros (--pi-color-white, --pi-color-grey-*,
--pi-color-black) — las paletas de marca
(primary, accent…) se dejan igual:
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.
nx g @pi/ui:theme <nombre> — hace los tres puntos siguientes por ti.themes/, exporta un mixin y una clase .pi-shell__<nombre>.themes/index.scss, al final de ambos bloques (@forward y @use).ShellClass (configs.ts) y en PiThemeService (ThemeVariant/shellClassByVariant), con un comentario de una línea que resuma el aspecto._default.scss sin repetirlo.--pi-color-primary/accent/highlighted, revisado que el puente --mat-sys-* (declarado una vez en default) sigue coherente..component.scss, nx run ui:lint-styles no marca ese fichero (detecta --pi-color-* usado fuera de un fallback --pi-theme-*)..pi-button.mi-tema) dentro del fichero del tema — si hace falta, el token que falta va al contrato compartido, no aquí.warn / success por estética — son colores semánticos; cambiarlos perjudica la lectura del estado (error/éxito).--pi-theme-* alcanza a los controles nativos de Material (checkbox, radio, slider): esos usan tokens --mdc-* fijados en compilación, fuera de este contrato (ver §1).