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

@@ -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
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 |
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
| 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
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';
// en el elemento raíz (normalmente <body> o el contenedor de la app)
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-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-*`)
@@ -236,12 +259,55 @@ compilación y no forman parte del contrato de theming en runtime.
## 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.

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-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-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>
</table>
<div class="callout">
Minimal declara los seis en su mixin (<code>_minimal.scss</code>): aplana
todos los radios a <code>--pi-border-radius-xx-small</code> y apaga las dos
sombras (<code>none</code>) — mismo criterio que ya aplicaba a botón, campo
y card, extendido al resto de superficies flotantes.
Minimal declara los seis primeros en su mixin (<code>_minimal.scss</code>):
aplana todos los radios a <code>--pi-border-radius-xx-small</code> y apaga
las dos sombras (<code>none</code>) — 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.
</div>
<!-- ================= §3 CREAR UN TEMA ================= -->
<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>
<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-num">1</div>
<div class="step-body">
@@ -523,7 +536,13 @@
<div class="step-num">4</div>
<div class="step-body">
<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';
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
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
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>,
<code>--pi-color-black</code>) — las paletas de marca
(<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>
<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>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>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 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>
</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",
"lib": {
"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",
"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": {
"@angular/animations": "^22.0.0",
"@angular/cdk": "^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
}

View File

@@ -52,6 +52,27 @@
},
"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/color';
export * from './lib/util/common';
export * from './lib/util/dato-cms';
export * from './lib/util/device-configs';
export * from './lib/util/environments';
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/scroll-to';
export * from './lib/util/status';
export * from './lib/util/theme';
export * from './lib/util/translate';
export * from './lib/util/validator';
export * from './lib/util/value';

View File

@@ -10,7 +10,7 @@
justify-content: space-between;
gap: var(--pi-spacing-medium);
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 {

View File

@@ -10,7 +10,7 @@
height: 0.25rem;
margin: var(--pi-spacing-small) auto 0;
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 {
@@ -19,7 +19,7 @@
justify-content: space-between;
gap: var(--pi-spacing-medium);
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 {

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"]
}