This commit is contained in:
2026-08-06 20:51:46 +02:00
parent f86db1556c
commit d1b70ea7d2
30 changed files with 1601 additions and 260 deletions

View File

@@ -7,13 +7,13 @@
# Redirige solo el scope @pi al repositorio npm (hosted) de Nexus. # Redirige solo el scope @pi al repositorio npm (hosted) de Nexus.
# Cambia la URL por la de tu instancia y el nombre de tu repo npm-hosted. # Cambia la URL por la de tu instancia y el nombre de tu repo npm-hosted.
@pi:registry=https://nexus.tuempresa.com/repository/npm-private/ @pi:registry=https://nexus.pireon.dev/repository/npm-private/
# Autenticación por token (recomendado, funciona igual en local y en CI). # Autenticación por token (recomendado, funciona igual en local y en CI).
# El token se genera en Nexus: usuario > Security > NuGet/npm token, o vía # El token se genera en Nexus: usuario > Security > NuGet/npm token, o vía
# `npm login --registry=https://nexus.tuempresa.com/repository/npm-private/` # `npm login --registry=https://nexus.pireon.dev/repository/npm-private/`
# (esto último escribe la línea de abajo automáticamente en tu ~/.npmrc). # (esto último escribe la línea de abajo automáticamente en tu ~/.npmrc).
//nexus.tuempresa.com/repository/npm-private/:_authToken=${NEXUS_NPM_TOKEN} //nexus.pireon.dev/repository/npm-private/:_authToken=${NEXUS_NPM_TOKEN}
# Nexus no es el registro público de npm: sin este flag, `npm publish` # Nexus no es el registro público de npm: sin este flag, `npm publish`
# rechaza paquetes con scope si no lleva --access, y con un registro privado # rechaza paquetes con scope si no lleva --access, y con un registro privado

13
.stylelintrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"plugins": ["./tools/stylelint-plugin-pi-theme/no-raw-color-token.mjs"],
"customSyntax": "postcss-scss",
"ignoreFiles": [
"libs/ui/src/lib/styles/themes/**/*.scss",
"libs/ui/src/lib/styles/common/abstracts/**/*.scss",
"dist/**",
"node_modules/**"
],
"rules": {
"pi/no-raw-color-token": true
}
}

View File

@@ -16,9 +16,9 @@
<button <button
type="button" type="button"
role="radio" role="radio"
[attr.aria-checked]="theme() === option.value" [attr.aria-checked]="themeService.theme() === option.value"
[class.is-active]="theme() === option.value" [class.is-active]="themeService.theme() === option.value"
(click)="theme.set(option.value)" (click)="themeService.setTheme(option.value)"
> >
{{ option.label }} {{ option.label }}
</button> </button>
@@ -26,7 +26,7 @@
</div> </div>
<label class="theme-toggle"> <label class="theme-toggle">
<span>Modo oscuro</span> <span>Modo oscuro</span>
<pi-slide-toggle [(checked)]="darkMode" /> <pi-slide-toggle [(checked)]="themeService.darkMode" />
</label> </label>
</div> </div>
</div> </div>
@@ -43,8 +43,8 @@
</nav> </nav>
<p class="theme-hint"> <p class="theme-hint">
Tema: <strong>{{ theme() }}</strong> · Tema: <strong>{{ themeService.theme() }}</strong> ·
<code>{{ shellClassByTheme[theme()] }}</code> <code>{{ themeService.shellClass() }}</code>
en el <code>&lt;body&gt;</code>. Los cuatro temas comparten exactamente los en el <code>&lt;body&gt;</code>. Los cuatro temas comparten exactamente los
mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y
<code>--pi-theme-*</code> que definen <code>themes/_default.scss</code>, <code>--pi-theme-*</code> que definen <code>themes/_default.scss</code>,

View File

@@ -1,14 +1,12 @@
import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
} from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { PiLoaderBarComponent, PiSlideToggleComponent, ShellClass } from '@pi/ui'; import {
PiLoaderBarComponent,
PiSlideToggleComponent,
PiThemeService,
ThemeVariant,
} from '@pi/ui';
/** /**
* Shell del playground: cabecera, navegación y estado de tema. El contenido de * Shell del playground: cabecera, navegación y estado de tema. El contenido de
@@ -38,9 +36,12 @@ export class App {
/* --- Tema y modo oscuro -------------------------------------------------- */ /* --- Tema y modo oscuro -------------------------------------------------- */
private readonly document = inject(DOCUMENT); /**
* Aplicar `pi-shell pi-shell__<tema>` en `<body>` y `dark-mode` en `<html>`
protected readonly darkMode = signal(false); * (con persistencia en `localStorage`) vive en `PiThemeService`, no aquí:
* cualquier app que consuma `@pi/ui` necesita este mismo cableado.
*/
protected readonly themeService = inject(PiThemeService);
protected readonly themeOptions: ReadonlyArray<{ protected readonly themeOptions: ReadonlyArray<{
value: ThemeVariant; value: ThemeVariant;
@@ -51,34 +52,4 @@ export class App {
{ value: 'minimal', label: 'Minimal' }, { value: 'minimal', label: 'Minimal' },
{ value: 'gradient', label: 'Gradient' }, { value: 'gradient', label: 'Gradient' },
]; ];
/**
* Tema visual de la librería. `ShellClass` devuelve las dos clases que hay
* que poner juntas (`pi-shell pi-shell__<tema>`); van en el `<body>` porque
* los overlays de Material (diálogos, snackbars) se montan fuera de
* `<app-root>` y también deben heredarlas.
*/
protected readonly theme = signal<ThemeVariant>('default');
protected readonly shellClassByTheme: Record<ThemeVariant, string> = {
default: ShellClass.Default,
alternative: ShellClass.Alternative,
minimal: ShellClass.Minimal,
gradient: ShellClass.Gradient,
};
constructor() {
effect(() => {
this.document.documentElement.classList.toggle(
'dark-mode',
this.darkMode(),
);
});
effect(() => {
this.document.body.className = this.shellClassByTheme[this.theme()];
});
}
} }
type ThemeVariant = 'default' | 'alternative' | 'minimal' | 'gradient';

View File

@@ -80,6 +80,24 @@ en custom properties (`--pi-color-*`, `--pi-theme-*`) aplicadas vía `ShellClass
tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas
nuevos: [`TOKENS.md`](./TOKENS.md). nuevos: [`TOKENS.md`](./TOKENS.md).
Tres piezas pensadas para que reskinear la librería en otro proyecto sea
barato:
- **`PiThemeService`** (`util/theme`) — inyectable, con `theme`/`darkMode`
como señales normales (mutables con setters o con `[(value)]`/`[(checked)]`
directo), persistencia en `localStorage` y aplicación de clases vía
`effect()`. Sustituye al cableado manual que antes vivía en cada app
consumidora (ver el `app.ts` del playground antes/después de esta
extracción).
- **`nx g @pi/ui:theme <nombre>`** — genera `_<nombre>.scss`, lo registra en
`themes/index.scss`, añade la entrada a `ShellClass` y extiende
`PiThemeService`; el tema queda seleccionable sin pasos manuales.
- **`nx run ui:lint-styles`** — regla de stylelint (`pi/no-raw-color-token`)
que detecta un `--pi-color-*` usado fuera del fallback de un
`--pi-theme-*`, la misma clase de bug que dejó colores de marca fijos en
modo oscuro (accordion/tab/select-option, antes de tokenizarlos). Target
aparte por ahora — ver TOKENS.md para el porqué y el estado de adopción.
| Componente | Contrato | Tipo del valor | | Componente | Contrato | Tipo del valor |
| ---------------------------------------------------- | --------------------- | ------------------------------------- | | ---------------------------------------------------- | --------------------- | ------------------------------------- |
| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` | | text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |

View File

@@ -20,10 +20,31 @@ componente — solo redefine tokens (ver `_alternative.scss`, `_minimal.scss`,
## Cómo se aplica un tema ## 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 ```ts
import { ShellClass } from '@pi/ui'; import { ShellClass } from '@pi/ui';
// en el elemento raíz (normalmente <body> o el contenedor de la app)
document.body.className = ShellClass.Alternative; // 'pi-shell pi-shell__alternative' document.body.className = ShellClass.Alternative; // 'pi-shell pi-shell__alternative'
``` ```
@@ -122,6 +143,8 @@ siempre el *fallback* de `var()` (el valor que tenían antes de tokenizar).
| `--pi-theme-tooltip-radius` | `var(--pi-border-radius-medium)` | `var(--pi-border-radius-xx-small)` | `_mat-tooltip.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-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-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-*`) ### Puente con Angular Material (`--mat-sys-*`)
@@ -236,12 +259,55 @@ compilación y no forman parte del contrato de theming en runtime.
## Cómo añadir un tema nuevo ## 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 1. Crea `libs/ui/src/lib/styles/themes/_<nombre>.scss` con un mixin
`<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si `<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si
aplica, `--pi-color-*`) que cambian respecto a Default. aplica, `--pi-color-*`) que cambian respecto a Default.
2. Aplícalo a `.pi-shell__<nombre>`. 2. Aplícalo a `.pi-shell__<nombre>`.
3. Impórtalo desde `themes/index.scss`. 3. Impórtalo desde `themes/index.scss`.
4. Añade la entrada a `ShellClass` en `styles/configs.ts`. 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 No hace falta tocar ningún componente: si consumen los tokens en vez de
valores literales, el tema nuevo se propaga solo. 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.

Binary file not shown.

View File

@@ -454,19 +454,32 @@
<tr><td class="tok"><code class="token">--pi-theme-tooltip-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-medium)</code></td><td><code>_mat-tooltip.scss</code></td></tr> <tr><td class="tok"><code class="token">--pi-theme-tooltip-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-medium)</code></td><td><code>_mat-tooltip.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-tooltip-shadow</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-box-shadow-panel-double)</code></td><td><code>_mat-tooltip.scss</code></td></tr> <tr><td class="tok"><code class="token">--pi-theme-tooltip-shadow</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-box-shadow-panel-double)</code></td><td><code>_mat-tooltip.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-accordion-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-large)</code></td><td><code>accordion.component.scss</code>, <code>_horizontal-accordion.scss</code>, <code>_vertical-accordion.scss</code> (×6, incluye un <code>calc()</code>)</td></tr> <tr><td class="tok"><code class="token">--pi-theme-accordion-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-large)</code></td><td><code>accordion.component.scss</code>, <code>_horizontal-accordion.scss</code>, <code>_vertical-accordion.scss</code> (×6, incluye un <code>calc()</code>)</td></tr>
<tr><td class="tok"><code class="token">--pi-theme-overlay-divider-color</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-color-grey-100)</code></td><td><code>drawer.component.scss</code>, <code>hub.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-hub-grabber-color</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-color-grey-100)</code></td><td><code>hub.component.scss</code></td></tr>
</tbody> </tbody>
</table> </table>
<div class="callout"> <div class="callout">
Minimal declara los seis en su mixin (<code>_minimal.scss</code>): aplana Minimal declara los seis primeros en su mixin (<code>_minimal.scss</code>):
todos los radios a <code>--pi-border-radius-xx-small</code> y apaga las dos aplana todos los radios a <code>--pi-border-radius-xx-small</code> y apaga
sombras (<code>none</code>) — mismo criterio que ya aplicaba a botón, campo las dos sombras (<code>none</code>) — mismo criterio que ya aplicaba a
y card, extendido al resto de superficies flotantes. 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.
</div> </div>
<!-- ================= §3 CREAR UN TEMA ================= --> <!-- ================= §3 CREAR UN TEMA ================= -->
<h1 class="section-title" style="margin-top:26px"><span class="num">3</span>Crear un tema, paso a paso</h1> <h1 class="section-title" style="margin-top:26px"><span class="num">3</span>Crear un tema, paso a paso</h1>
<p class="lede">Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio <code>brutalist</code>.</p> <p class="lede">Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio <code>brutalist</code>.</p>
<div class="callout">
Los cuatro pasos de esta página los da un generador:
<code>nx g @pi/ui:theme brutalist</code>. 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 <code>PiThemeService</code> (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.
</div>
<div class="step"> <div class="step">
<div class="step-num">1</div> <div class="step-num">1</div>
<div class="step-body"> <div class="step-body">
@@ -523,7 +536,13 @@
<div class="step-num">4</div> <div class="step-num">4</div>
<div class="step-body"> <div class="step-body">
<div class="step-title">Aplícalo en la app consumidora</div> <div class="step-title">Aplícalo en la app consumidora</div>
<p>En el playground esto ya está resuelto con un signal + effect (ver <code>apps/playground/src/app/app.ts</code>); en cualquier otra app basta con:</p> <p>Con <code>PiThemeService</code> (ver <code>apps/playground/src/app/app.ts</code>) basta con:</p>
<pre class="code">import { inject } from '@angular/core';
import { PiThemeService } from '@pi/ui';
const themeService = inject(PiThemeService);
themeService.setTheme('brutalist');</pre>
<p>Si el generador ya extendió <code>ThemeVariant</code>/<code>shellClassByVariant</code> (paso automático 4), no hace falta tocar nada más que este <code>setTheme</code>. Sin el servicio, el mecanismo de bajo nivel sigue funcionando igual:</p>
<pre class="code">import { ShellClass } from '@pi/ui'; <pre class="code">import { ShellClass } from '@pi/ui';
document.body.className = ShellClass.Brutalist;</pre> document.body.className = ShellClass.Brutalist;</pre>
@@ -654,7 +673,11 @@ document.body.className = ShellClass.Brutalist;</pre>
<code>@pi/ui</code> no trae un tema oscuro propio. El playground activa <code>@pi/ui</code> no trae un tema oscuro propio. El playground activa
uno "best-effort" añadiendo la clase <code>dark-mode</code> al uno "best-effort" añadiendo la clase <code>dark-mode</code> al
<code>&lt;html&gt;</code> (no al <code>&lt;body&gt;</code>, que es donde <code>&lt;html&gt;</code> (no al <code>&lt;body&gt;</code>, que es donde
vive <code>.pi-shell</code>), y ahí reasigna solo los vive <code>.pi-shell</code>) — hoy vía
<code>PiThemeService.setDarkMode()</code>/<code>toggleDarkMode()</code>,
que hace ese toggle por ti (persistido en <code>localStorage</code>) en
vez de que cada app repita el mismo <code>effect()</code> — y ahí reasigna
solo los
<b>neutros</b> (<code>--pi-color-white</code>, <code>--pi-color-grey-*</code>, <b>neutros</b> (<code>--pi-color-white</code>, <code>--pi-color-grey-*</code>,
<code>--pi-color-black</code>) — las paletas de marca <code>--pi-color-black</code>) — las paletas de marca
(<code>primary</code>, <code>accent</code>…) se dejan igual: (<code>primary</code>, <code>accent</code>…) se dejan igual:
@@ -684,12 +707,14 @@ document.body.className = ShellClass.Brutalist;</pre>
<h2 class="sub" style="margin-top:6px">Antes de dar el tema por terminado</h2> <h2 class="sub" style="margin-top:6px">Antes de dar el tema por terminado</h2>
<ul class="check"> <ul class="check">
<li>Se creó con <code>nx g @pi/ui:theme &lt;nombre&gt;</code> — hace los tres puntos siguientes por ti.</li>
<li>El fichero nuevo vive en <code>themes/</code>, exporta un mixin y una clase <code>.pi-shell__&lt;nombre&gt;</code>.</li> <li>El fichero nuevo vive en <code>themes/</code>, exporta un mixin y una clase <code>.pi-shell__&lt;nombre&gt;</code>.</li>
<li>Está en <code>themes/index.scss</code>, al final de ambos bloques (<code>@forward</code> y <code>@use</code>).</li> <li>Está en <code>themes/index.scss</code>, al final de ambos bloques (<code>@forward</code> y <code>@use</code>).</li>
<li>Tiene entrada en <code>ShellClass</code> (<code>configs.ts</code>), con un comentario de una línea que resuma el aspecto.</li> <li>Tiene entrada en <code>ShellClass</code> (<code>configs.ts</code>) y en <code>PiThemeService</code> (<code>ThemeVariant</code>/<code>shellClassByVariant</code>), con un comentario de una línea que resuma el aspecto.</li>
<li>Solo declara los tokens que cambia — el resto hereda de <code>_default.scss</code> sin repetirlo.</li> <li>Solo declara los tokens que cambia — el resto hereda de <code>_default.scss</code> sin repetirlo.</li>
<li>Si toca <code>--pi-color-primary/accent/highlighted</code>, revisado que el puente <code>--mat-sys-*</code> (declarado una vez en default) sigue coherente.</li> <li>Si toca <code>--pi-color-primary/accent/highlighted</code>, revisado que el puente <code>--mat-sys-*</code> (declarado una vez en default) sigue coherente.</li>
<li>Si toca overlays (modal/notificación/tooltip/accordion), revisado con las tres páginas de catálogo, no solo con un botón suelto.</li> <li>Si toca overlays (modal/notificación/tooltip/accordion), revisado con las tres páginas de catálogo, no solo con un botón suelto.</li>
<li>Si al tokenizar una superficie nueva tocaste un <code>.component.scss</code>, <code>nx run ui:lint-styles</code> no marca ese fichero (detecta <code>--pi-color-*</code> usado fuera de un fallback <code>--pi-theme-*</code>).</li>
<li>Probado en el playground con las tres páginas de catálogo, no solo en un botón suelto.</li> <li>Probado en el playground con las tres páginas de catálogo, no solo en un botón suelto.</li>
</ul> </ul>

Binary file not shown.

10
libs/ui/generators.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/nrwl/nx/master/packages/nx/schemas/generators.json",
"generators": {
"theme": {
"factory": "./src/generators/theme/generator.cts",
"schema": "./src/generators/theme/schema.json",
"description": "Crea un nuevo tema (_<nombre>.scss + ShellClass + PiThemeService) para @pi/ui."
}
}
}

View File

@@ -3,5 +3,13 @@
"dest": "../../dist/libs/ui", "dest": "../../dist/libs/ui",
"lib": { "lib": {
"entryFile": "src/index.ts" "entryFile": "src/index.ts"
} },
"allowedNonPeerDependencies": ["lodash", "luxon", "ngx-infinite-scroll"],
"assets": [
{
"glob": "**/*.scss",
"input": "src/lib/styles",
"output": "/styles"
}
]
} }

View File

@@ -1,9 +1,29 @@
{ {
"name": "@pi/ui", "name": "@pi/ui",
"version": "0.0.1", "version": "0.0.1",
"description": "Librería de componentes Angular standalone (botones, campos con Signal Forms, cards, charts, drawer/hub/modal, theming vía tokens CSS...).",
"license": "MIT",
"generators": "./generators.json",
"publishConfig": {
"registry": "https://nexus.pireon.dev/repository/npm-private/",
"access": "restricted"
},
"peerDependencies": { "peerDependencies": {
"@angular/animations": "^22.0.0",
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/core": "^22.0.0" "@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/router": "^22.0.0",
"@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0",
"chart.js": "^4.0.0"
},
"dependencies": {
"lodash": "^4.17.0",
"luxon": "^3.0.0",
"ngx-infinite-scroll": "^22.0.0"
}, },
"sideEffects": false "sideEffects": false
} }

View File

@@ -52,6 +52,27 @@
}, },
"lint": { "lint": {
"executor": "@nx/eslint:lint" "executor": "@nx/eslint:lint"
},
"lint-styles": {
"executor": "nx:run-commands",
"options": {
"command": "stylelint \"libs/ui/src/**/*.scss\" --config .stylelintrc.json"
}
},
"build-generators": {
"executor": "nx:run-commands",
"dependsOn": ["package"],
"outputs": [
"{workspaceRoot}/dist/{projectRoot}/generators",
"{workspaceRoot}/dist/{projectRoot}/generators.json"
],
"options": {
"commands": [
"tsc -p libs/ui/tsconfig.generators.json",
"node libs/ui/scripts/finalize-generators-dist.cjs"
],
"parallel": false
}
} }
} }
} }

View File

@@ -0,0 +1,55 @@
'use strict';
/**
* `ng-packagr` (el `build`/`package` de `ui`) solo empaqueta lo alcanzable
* desde `src/index.ts` — los generadores (`src/generators/`) son Node/Nx
* plugin, no Angular, así que ng-packagr los ignora por completo. Este script
* los deja listos en `dist/libs/ui/` (ya compilados por
* `tsc -p tsconfig.generators.json`, que corre antes que este script) para
* que `generators.json` publicado apunte a ficheros que de verdad existen en
* el tarball — si no, `nx g @pi/ui:theme` funcionaría dentro de este
* monorepo pero fallaría para quien instale el paquete desde el registry.
*/
const fs = require('fs');
const path = require('path');
const libRoot = path.join(__dirname, '..');
const distRoot = path.join(libRoot, '..', '..', 'dist', 'libs', 'ui');
const sourceManifest = JSON.parse(
fs.readFileSync(path.join(libRoot, 'generators.json'), 'utf8'),
);
/** `./src/generators/theme/generator.cts` (fuente) -> `./generators/theme/generator.cjs` (dist). */
function toDistPath(sourcePath) {
return sourcePath.replace('./src/generators/', './generators/').replace(/\.cts$/, '.cjs');
}
const distManifest = {
...sourceManifest,
generators: Object.fromEntries(
Object.entries(sourceManifest.generators).map(([name, config]) => [
name,
{
...config,
factory: toDistPath(config.factory),
schema: toDistPath(config.schema),
},
]),
),
};
fs.writeFileSync(
path.join(distRoot, 'generators.json'),
JSON.stringify(distManifest, null, 2) + '\n',
);
for (const { schema } of Object.values(sourceManifest.generators)) {
const sourceSchemaPath = path.join(libRoot, schema);
const destSchemaPath = path.join(distRoot, toDistPath(schema));
fs.mkdirSync(path.dirname(destSchemaPath), { recursive: true });
fs.copyFileSync(sourceSchemaPath, destSchemaPath);
}
console.log('✔ generators.json + schemas listos en dist/libs/ui/');

View File

@@ -0,0 +1,172 @@
import { Tree, formatFiles, logger, names } from '@nx/devkit';
import type { ThemeGeneratorSchema } from './schema';
const THEMES_DIR = 'libs/ui/src/lib/styles/themes';
const CONFIGS_PATH = 'libs/ui/src/lib/styles/configs.ts';
const THEME_SERVICE_PATH = 'libs/ui/src/lib/util/theme/theme.service.ts';
export default async function themeGenerator(
tree: Tree,
options: ThemeGeneratorSchema,
): Promise<void> {
const { fileName: themeName, className, propertyName } = names(options.name);
const scssPath = `${THEMES_DIR}/_${themeName}.scss`;
if (tree.exists(scssPath)) {
throw new Error(
`Ya existe un tema en ${scssPath}. Elige otro nombre o bórralo primero.`,
);
}
tree.write(scssPath, buildThemeScss(themeName, propertyName));
registerInThemesIndex(tree, themeName);
addShellClassEntry(tree, className, themeName);
extendThemeService(tree, className, themeName);
await formatFiles(tree);
logger.info(`\n✔ Tema "${themeName}" creado:`);
logger.info(` - ${scssPath}`);
logger.info(` - registrado en ${THEMES_DIR}/index.scss`);
logger.info(` - añadido a ShellClass en ${CONFIGS_PATH}`);
logger.info(` - añadido a ThemeVariant/PiThemeService en ${THEME_SERVICE_PATH}`);
logger.info(
`\nEdita los tokens de ${scssPath} para definir el aspecto (ver TOKENS.md).`,
);
logger.info(
`Ya es seleccionable vía PiThemeService.setTheme('${themeName}'); solo falta` +
` añadirlo a la UI de tu app (p. ej. \`themeOptions\` en tu shell) si quieres un botón.`,
);
}
function buildThemeScss(themeName: string, propertyName: string): string {
return `// TEMA ${themeName.toUpperCase()}
//
// Generado con "nx g @pi/ui:theme ${themeName}". Empieza en blanco a
// propósito: mientras no descomentes nada, este tema es visualmente idéntico
// al tema por defecto (todo cae al ".pi-shell" base). Descomenta y cambia solo
// los tokens que quieras redefinir — no hace falta declarar el resto. Lista
// completa de tokens "--pi-theme-*"/"--pi-color-*" disponibles: TOKENS.md.
@mixin ${propertyName}ThemeTokens {
// --- Paleta de marca ----------------------------------------------------
// --pi-color-primary: #799ef0;
// --pi-color-accent: #eda82a;
// --pi-color-highlighted: #0d2a6b;
// --- Botones --------------------------------------------------------------
// --pi-theme-button-radius: 999px;
// --pi-theme-button-shadow: none;
// --pi-theme-button-fill-primary: none;
// --- Campos de formulario ---------------------------------------------------
// --pi-theme-field-radius: 0.875rem;
// --pi-theme-field-border-color-focus: var(--pi-color-primary);
// --pi-theme-field-surface: var(--pi-color-white);
// --- Superficies ------------------------------------------------------------
// --pi-theme-card-radius: var(--pi-border-radius-large);
// --pi-theme-card-shadow: var(--pi-box-shadow-panel);
// --pi-theme-panel-radius: var(--pi-border-radius-large);
}
.pi-shell__${themeName} {
@include ${propertyName}ThemeTokens;
}
`;
}
function registerInThemesIndex(tree: Tree, themeName: string): void {
const path = `${THEMES_DIR}/index.scss`;
const content = tree.read(path, 'utf-8');
if (!content) throw new Error(`No se encontró ${path}.`);
const withForward = insertAfterLastMatch(
content,
/@forward '[^']+';/g,
`@forward '${themeName}';`,
);
const withUse = insertAfterLastMatch(
withForward,
/@use '[^']+' as \*;/g,
`@use '${themeName}' as *;`,
);
tree.write(path, withUse);
}
function addShellClassEntry(
tree: Tree,
className: string,
themeName: string,
): void {
patchObjectLiteral(
tree,
CONFIGS_PATH,
/export const ShellClass = \{[\s\S]*?\n\};/,
` /** Generado con \`nx g @pi/ui:theme ${themeName}\`. */\n ${className}: shellTheming('${themeName}'),\n};`,
);
}
function extendThemeService(
tree: Tree,
className: string,
themeName: string,
): void {
const path = THEME_SERVICE_PATH;
const content = tree.read(path, 'utf-8');
if (!content) throw new Error(`No se encontró ${path}.`);
const unionPattern =
/export type ThemeVariant = 'default' \| 'alternative' \| 'minimal' \| 'gradient'(?:\s*\|\s*'[^']+')*;/;
const updatedUnion = content.replace(unionPattern, (match) =>
match.replace(/;$/, ` | '${themeName}';`),
);
patchObjectLiteral(
tree,
path,
/const shellClassByVariant: Record<ThemeVariant, string> = \{[\s\S]*?\n\};/,
` '${themeName}': ShellClass.${className},\n};`,
updatedUnion,
);
}
/**
* Sustituye el objeto literal que casa con `blockPattern` insertando
* `newClosing` justo antes de su `\n};` de cierre — funciona igual si el
* generador ya se ha ejecutado antes (siempre inserta antes del PRIMER cierre
* tras la apertura, no del último del fichero).
*/
function patchObjectLiteral(
tree: Tree,
path: string,
blockPattern: RegExp,
newClosing: string,
contentOverride?: string,
): void {
const content = contentOverride ?? tree.read(path, 'utf-8');
if (!content) throw new Error(`No se encontró ${path}.`);
const match = content.match(blockPattern);
if (!match) {
throw new Error(`No se encontró el bloque esperado en ${path}.`);
}
const block = match[0];
const updatedBlock = block.replace(/\n\};$/, `\n${newClosing}`);
tree.write(path, content.replace(block, updatedBlock));
}
function insertAfterLastMatch(
content: string,
pattern: RegExp,
newLine: string,
): string {
const matches = [...content.matchAll(pattern)];
if (matches.length === 0) return `${content}\n${newLine}`;
const last = matches[matches.length - 1];
const insertPos = (last.index ?? content.length) + last[0].length;
return `${content.slice(0, insertPos)}\n${newLine}${content.slice(insertPos)}`;
}

View File

@@ -0,0 +1,3 @@
export interface ThemeGeneratorSchema {
name: string;
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "PiUiTheme",
"title": "Genera un nuevo tema para @pi/ui",
"description": "Crea _<nombre>.scss, lo registra en themes/index.scss, añade una entrada a ShellClass (styles/configs.ts) y extiende ThemeVariant/PiThemeService para que quede utilizable de inmediato.",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Nombre del tema, en kebab-case (p. ej. \"acme-corp\").",
"$default": { "$source": "argv", "index": 0 },
"x-prompt": "¿Cómo se llama el tema?"
}
},
"required": ["name"]
}

View File

@@ -6,7 +6,6 @@ export * from './lib/util/affix';
export * from './lib/util/amplitude'; export * from './lib/util/amplitude';
export * from './lib/util/color'; export * from './lib/util/color';
export * from './lib/util/common'; export * from './lib/util/common';
export * from './lib/util/dato-cms';
export * from './lib/util/device-configs'; export * from './lib/util/device-configs';
export * from './lib/util/environments'; export * from './lib/util/environments';
export * from './lib/util/icon-registry'; export * from './lib/util/icon-registry';
@@ -21,6 +20,7 @@ export * from './lib/util/router-store';
export * from './lib/util/schema'; export * from './lib/util/schema';
export * from './lib/util/scroll-to'; export * from './lib/util/scroll-to';
export * from './lib/util/status'; export * from './lib/util/status';
export * from './lib/util/theme';
export * from './lib/util/translate'; export * from './lib/util/translate';
export * from './lib/util/validator'; export * from './lib/util/validator';
export * from './lib/util/value'; export * from './lib/util/value';

View File

@@ -10,7 +10,7 @@
justify-content: space-between; justify-content: space-between;
gap: var(--pi-spacing-medium); gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-large) var(--pi-spacing-x-large); padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
border-bottom: 0.0625rem solid var(--pi-color-grey-100); border-bottom: 0.0625rem solid var(--pi-theme-overlay-divider-color, var(--pi-color-grey-100));
} }
main { main {

View File

@@ -10,7 +10,7 @@
height: 0.25rem; height: 0.25rem;
margin: var(--pi-spacing-small) auto 0; margin: var(--pi-spacing-small) auto 0;
border-radius: var(--pi-border-radius-x-small); border-radius: var(--pi-border-radius-x-small);
background-color: var(--pi-color-grey-100); background-color: var(--pi-theme-hub-grabber-color, var(--pi-color-grey-100));
} }
header { header {
@@ -19,7 +19,7 @@
justify-content: space-between; justify-content: space-between;
gap: var(--pi-spacing-medium); gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large); padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large);
border-bottom: 0.0625rem solid var(--pi-color-grey-100); border-bottom: 0.0625rem solid var(--pi-theme-overlay-divider-color, var(--pi-color-grey-100));
} }
main { main {

View File

@@ -1 +0,0 @@
export const datoCms = 'https://graphql.datocms.com/'; // our GraphQL API

View File

@@ -1 +0,0 @@
export * from './util-dato-cms.module';

View File

@@ -1,36 +0,0 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
// Apollo
import { InMemoryCache } from '@apollo/client/core';
import { APOLLO_OPTIONS } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http';
// Shared
import { environment } from '../environments';
// datoCMS
import { datoCms } from './graphql.module';
/**
* Sustituye a `PiSharedUtilDatoCmsModule`: apollo-angular ya no expone
* `ApolloModule`, la configuración se hace con providers.
*/
export function providePiDatoCms(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: APOLLO_OPTIONS,
useFactory: (httpLink: HttpLink) => {
return {
cache: new InMemoryCache(),
link: httpLink.create({
uri: datoCms,
headers: {
Authorization: environment.DATO_KEY,
} as any,
}),
};
},
deps: [HttpLink],
},
]);
}

View File

@@ -0,0 +1 @@
export * from './theme.service';

View File

@@ -0,0 +1,60 @@
import { TestBed } from '@angular/core/testing';
import { PiThemeService } from './theme.service';
describe('PiThemeService', () => {
afterEach(() => {
localStorage.clear();
document.body.className = '';
document.documentElement.classList.remove('dark-mode');
});
it('applies `pi-shell pi-shell__default` to <body> by default', () => {
TestBed.inject(PiThemeService);
TestBed.flushEffects();
expect(document.body.className).toBe('pi-shell pi-shell__default');
});
it('updates <body> when the theme changes', () => {
const service = TestBed.inject(PiThemeService);
service.setTheme('alternative');
TestBed.flushEffects();
expect(document.body.className).toBe('pi-shell pi-shell__alternative');
});
it('toggles `dark-mode` on <html>', () => {
const service = TestBed.inject(PiThemeService);
service.setDarkMode(true);
TestBed.flushEffects();
expect(document.documentElement.classList.contains('dark-mode')).toBe(true);
service.toggleDarkMode();
TestBed.flushEffects();
expect(document.documentElement.classList.contains('dark-mode')).toBe(false);
});
it('persists the theme and dark mode across a fresh instance (reload)', () => {
const service = TestBed.inject(PiThemeService);
service.setTheme('minimal');
service.setDarkMode(true);
TestBed.flushEffects();
TestBed.resetTestingModule();
const reloaded = TestBed.inject(PiThemeService);
expect(reloaded.theme()).toBe('minimal');
expect(reloaded.darkMode()).toBe(true);
});
it('falls back to the default theme when nothing valid is stored', () => {
localStorage.setItem('pi-theme', JSON.stringify('not-a-real-theme'));
const service = TestBed.inject(PiThemeService);
expect(service.theme()).toBe('default');
});
});

View File

@@ -0,0 +1,98 @@
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
Injectable,
PLATFORM_ID,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { ShellClass } from '../../styles';
import { LocalStorageService } from '../local-storage';
export type ThemeVariant = 'default' | 'alternative' | 'minimal' | 'gradient';
const THEME_STORAGE_KEY = 'pi-theme';
const DARK_MODE_STORAGE_KEY = 'pi-dark-mode';
const shellClassByVariant: Record<ThemeVariant, string> = {
default: ShellClass.Default,
alternative: ShellClass.Alternative,
minimal: ShellClass.Minimal,
gradient: ShellClass.Gradient,
};
/**
* Centraliza lo que hasta ahora cada app reimplementaba a mano (ver el
* `app.ts` del playground antes de esta extracción): fijar
* `pi-shell pi-shell__<tema>` en `<body>` — los overlays de Material (diálogo,
* snackbar) cuelgan fuera de `<app-root>` y también necesitan heredarlo — y
* `dark-mode` en `<html>`.
*
* `theme`/`darkMode` son señales normales: se pueden mutar tanto con
* `setTheme()`/`toggleDarkMode()` como con un `[(checked)]`/`[(value)]` dos
* vías directo sobre la señal. La aplicación de clases y la persistencia en
* `localStorage` viven en `effect()`, no en los setters, para que cualquier
* camino de escritura quede cubierto por igual.
*
* El modo oscuro en sí (qué tokens remapea `.dark-mode`) lo sigue definiendo
* cada app consumidora — este servicio solo pone o quita la clase.
*/
@Injectable({ providedIn: 'root' })
export class PiThemeService {
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly localStorage = inject(LocalStorageService);
readonly theme = signal<ThemeVariant>(this.readStoredTheme());
readonly darkMode = signal<boolean>(this.readStoredDarkMode());
readonly shellClass = computed(() => shellClassByVariant[this.theme()]);
constructor() {
effect(() => {
this.document.body.className = this.shellClass();
this.persist(THEME_STORAGE_KEY, this.theme());
});
effect(() => {
this.document.documentElement.classList.toggle(
'dark-mode',
this.darkMode(),
);
this.persist(DARK_MODE_STORAGE_KEY, this.darkMode());
});
}
setTheme(theme: ThemeVariant): void {
this.theme.set(theme);
}
setDarkMode(darkMode: boolean): void {
this.darkMode.set(darkMode);
}
toggleDarkMode(): void {
this.darkMode.update((value) => !value);
}
private persist(key: string, value: unknown): void {
if (isPlatformBrowser(this.platformId)) {
this.localStorage.setItem(key, value);
}
}
private readStoredTheme(): ThemeVariant {
if (!isPlatformBrowser(this.platformId)) return 'default';
const stored = this.localStorage.getItem(THEME_STORAGE_KEY);
return stored && stored in shellClassByVariant ? stored : 'default';
}
private readStoredDarkMode(): boolean {
return (
isPlatformBrowser(this.platformId) &&
!!this.localStorage.getItem(DARK_MODE_STORAGE_KEY)
);
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2021",
"outDir": "../../dist/libs/ui/generators",
"rootDir": "src/generators",
"declaration": false,
"sourceMap": false,
"types": ["node"]
},
"include": ["src/generators/**/*.cts"]
}

1019
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,13 +29,10 @@
"@angular/platform-server": "22.0.6", "@angular/platform-server": "22.0.6",
"@angular/router": "22.0.6", "@angular/router": "22.0.6",
"@angular/ssr": "22.0.6", "@angular/ssr": "22.0.6",
"@apollo/client": "^4.2.8",
"@ngx-translate/core": "^18.0.0", "@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0",
"apollo-angular": "^14.1.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"express": "^4.21.2", "express": "^4.21.2",
"graphql": "^17.0.2",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"ngx-infinite-scroll": "^22.0.0", "ngx-infinite-scroll": "^22.0.0",
@@ -97,7 +94,9 @@
"ng-packagr": "~22.0.0", "ng-packagr": "~22.0.0",
"nx": "23.1.0", "nx": "23.1.0",
"postcss": "^8.4.5", "postcss": "^8.4.5",
"postcss-scss": "^4.0.9",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"stylelint": "^17.14.1",
"supertest": "^7.1.4", "supertest": "^7.1.4",
"ts-jest": "^29.4.7", "ts-jest": "^29.4.7",
"ts-node": "10.9.1", "ts-node": "10.9.1",

View File

@@ -0,0 +1,98 @@
import stylelint from 'stylelint';
const { createPlugin, utils } = stylelint;
const { report, ruleMessages, validateOptions } = utils;
const ruleName = 'pi/no-raw-color-token';
const messages = ruleMessages(ruleName, {
rejected: (token) =>
`"${token}" se usa directamente. Los componentes de la librería deben ` +
`consumir un token --pi-theme-* con este color como fallback ` +
`(var(--pi-theme-<algo>, ${token})) para que un tema o el modo oscuro ` +
`puedan redefinir la superficie sin tocar el componente. Ver TOKENS.md.`,
});
const meta = {
url: 'https://github.com/pi/ui/tools/stylelint-plugin-pi-theme',
};
/**
* Contrato de esta librería (ver TOKENS.md): un componente NO consume
* `--pi-color-*` en crudo, lo hace a través de un `--pi-theme-*` con el color
* como fallback: `var(--pi-theme-x, var(--pi-color-y))`. Esta regla detecta
* el caso contrario — `--pi-color-*` referenciado fuera de cualquier
* fallback de `--pi-theme-*` — que es exactamente la clase de bug que dejó
* colores de marca fijos filtrando en modo oscuro (accordion/tab/select en
* esta misma librería, antes de tokenizarlos).
*
* Deliberadamente NO seguimos con `postcss-value-parser`: los valores aquí
* son casi siempre `var(...)` con como mucho una función envolvente
* (`linear-gradient(...)`, `rgba(...)`), así que un escaneo con paréntesis
* balanceados es suficiente y evita añadir una dependencia más solo para
* esta regla.
*/
function findMatchingParenEnd(value, openParenIndex) {
let depth = 1;
for (let i = openParenIndex + 1; i < value.length; i++) {
if (value[i] === '(') depth++;
else if (value[i] === ')') {
depth--;
if (depth === 0) return i;
}
}
return value.length - 1;
}
/** Rangos `[start, end)` de cada fallback de un `var(--pi-theme-*, ...)` — lo que haya dentro está exento. */
function findProtectedRanges(value) {
const ranges = [];
const pattern = /var\(\s*--pi-theme-[\w-]+/g;
let match;
while ((match = pattern.exec(value))) {
const openParenIndex = match.index + match[0].indexOf('(');
const end = findMatchingParenEnd(value, openParenIndex);
ranges.push([match.index, end]);
}
return ranges;
}
function isProtected(index, ranges) {
return ranges.some(([start, end]) => index > start && index < end);
}
const rule = (primaryOption) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primaryOption,
possible: [true, false],
});
if (!validOptions || !primaryOption) return;
root.walkDecls((decl) => {
const value = decl.value;
if (!value || !value.includes('--pi-color-')) return;
const protectedRanges = findProtectedRanges(value);
const colorPattern = /--pi-color-[\w-]+/g;
let match;
while ((match = colorPattern.exec(value))) {
if (isProtected(match.index, protectedRanges)) continue;
report({
message: messages.rejected(match[0]),
node: decl,
word: match[0],
result,
ruleName,
});
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
export default createPlugin(ruleName, rule);