From 5c631ae9ff0c1a75e5fa7201fcf7c5af55d632be Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 09:04:24 -0500 Subject: [PATCH 01/15] feat(lib): per-theme color-scheme hints for custom themes - Add NgColorScheme, NgThemeOption, NgThemeInput, ResolvedTheme types - normalizeThemeInputs() converts string/object theme arrays to ResolvedTheme[] with per-theme scheme hints - provideThemeStack() accepts string or object form (mixable), produces resolvedThemes on NgConfig - CoreThemeService.applyColorSchemeHint() uses per-theme lookup: auto/none removes the hint, light/dark sets it - Public services expose themeNames: string[] for template binding - ng-add schematic prompts for scheme hints, renders object-form entries, passes schemeMap to the anti-flash script - Anti-flash script sets color-scheme hint before Angular boots when schemeMap is provided - Sync schematic detects and preserves object-form themes - Demo app: added sepia theme with scheme: 'light' - Specs: types, config, core service, services, anti-flash Verified: tsc --noEmit clean for lib, schematics, and spec configs. --- odd/tasks/theme-color-scheme.md | 68 +++++++++ .../src/app/app.config.ts | 8 +- projects/demo-ngx-theme-stack/src/index.html | 3 +- projects/demo-ngx-theme-stack/src/themes.css | 28 ++++ .../schematics/ng-add/anti-flash.ts | 2 + .../schematics/ng-add/index.ts | 31 +++- .../schematics/ng-add/utils.ts | 10 +- .../ngx-theme-stack/schematics/sync/index.ts | 31 +++- .../schematics/utils/anti-flash-script.ts | 16 +- .../src/lib/config/index.spec.ts | 62 +++++++- .../ngx-theme-stack/src/lib/config/index.ts | 57 ++++++-- .../src/lib/core/core-theme.scheme.spec.ts | 134 +++++++++++++++++ .../src/lib/core/core-theme.service.spec.ts | 4 +- .../src/lib/core/core-theme.service.ts | 32 +++- .../lib/services/theme-cycle.service.spec.ts | 8 +- .../src/lib/services/theme-cycle.service.ts | 18 ++- .../lib/services/theme-select.service.spec.ts | 8 +- .../src/lib/services/theme-select.service.ts | 9 +- .../lib/services/theme-toggle.service.spec.ts | 3 +- .../src/lib/types.theme-scheme.spec.ts | 86 +++++++++++ projects/ngx-theme-stack/src/lib/types.ts | 137 ++++++++++++++++-- .../src/schematics/anti-flash-script.spec.ts | 52 +++++++ .../src/schematics/ng-add-utils.spec.ts | 32 ++++ 23 files changed, 785 insertions(+), 54 deletions(-) create mode 100644 odd/tasks/theme-color-scheme.md create mode 100644 projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts create mode 100644 projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts create mode 100644 projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts diff --git a/odd/tasks/theme-color-scheme.md b/odd/tasks/theme-color-scheme.md new file mode 100644 index 0000000..5174fed --- /dev/null +++ b/odd/tasks/theme-color-scheme.md @@ -0,0 +1,68 @@ +# Feature: Per-Theme Color Scheme Hints + +## Context + +Today, the library only applies a `color-scheme` hint for the built-in +`'light'` and `'dark'` themes. Custom themes (e.g. `sepia`, `ocean`) get no +hint at all — native UI widgets (scrollbars, inputs) don't match the active +custom theme. + +## Design + +Accept two input shapes in `provideThemeStack().themes` (mixable in one array): + +```ts +// Legacy: string-only (no hints on custom themes) +themes: ['sepia', 'ocean'] as const + +// New: object form with scheme hints +themes: [ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, +] as const +``` + +- `scheme` is `'light' | 'dark' | 'auto' | 'none'` (default `'auto'` when + omitted). +- `'auto'` and `'none'` → no `color-scheme` is set on the root element. +- `'light'` / `'dark'` → hint is set while that theme is active. +- Built-in themes keep their implicit hint (`'light'` → light, + `'dark'` → dark, `'system'` → auto). + +## Scope + +- **Types**: `NgColorScheme`, `NgThemeOption`, `NgThemeInput`, + `ResolvedTheme`, `normalizeThemeInputs()`, `implicitScheme()`. +- **Config**: `provideThemeStack()` normalizes inputs → `resolvedThemes`. +- **CoreThemeService**: applies per-theme `color-scheme` hint from the + normalized map; exposes `themeNames: string[]`. +- **Public services** (`ThemeSelectService`, `ThemeCycleService`): expose + `themeNames` for template binding. +- **ng-add schematic**: prompt for scheme hints on custom themes; render + object-form entries in `provideThemeStack()`; pass `schemeMap` to the + anti-flash script. +- **Anti-flash script**: when `schemeMap` is provided, sets the hint + before Angular boots. +- **Sync schematic**: detects object-form themes + schemeMap in the existing + config and regenerates the provider call + anti-flash script. +- **Demo**: `sepia` theme with `scheme: 'light'` added. + +## Tasks + +- [x] T1: Types (`types.ts`) +- [x] T2: `CoreThemeService` scheme hint logic +- [x] T3: Public services expose `themeNames` +- [x] T4: ng-add schematic (prompt + object form rendering) +- [x] T5: Anti-flash script respects per-theme scheme +- [x] T6: Sync schematic detects + preserves object form +- [x] T7: Specs (types, config, core, services, anti-flash) +- [x] T8: Demo app updated (sepia theme + index.html + themes.css) +- [ ] T9: Work-unit commit + verify build + +## Notes + +- Pre-existing test infra incident: `pnpm test` fails on this branch and on + main with a jsdom `MODULE_NOT_FOUND` (undici) error in the vitest worker. + This is not caused by this feature; it exists on main too. +- `tsc --noEmit` passes cleanly for `tsconfig.lib.json`, + `tsconfig.schematics.json`, and `tsconfig.spec.json`. diff --git a/projects/demo-ngx-theme-stack/src/app/app.config.ts b/projects/demo-ngx-theme-stack/src/app/app.config.ts index 8700eaa..ed9a2d1 100644 --- a/projects/demo-ngx-theme-stack/src/app/app.config.ts +++ b/projects/demo-ngx-theme-stack/src/app/app.config.ts @@ -16,7 +16,13 @@ export const appConfig: ApplicationConfig = { ), provideClientHydration(withEventReplay()), provideThemeStack({ - themes: ['system', 'light', 'dark', 'sunset'] as const, + themes: [ + 'system', + 'light', + 'dark', + { name: 'sunset', scheme: 'dark' }, + { name: 'sepia', scheme: 'light' }, + ] as const, defaultTheme: 'sunset', storageKey: 'ngx-theme-stack', mode: 'class', diff --git a/projects/demo-ngx-theme-stack/src/index.html b/projects/demo-ngx-theme-stack/src/index.html index 0bf3377..0a169f8 100644 --- a/projects/demo-ngx-theme-stack/src/index.html +++ b/projects/demo-ngx-theme-stack/src/index.html @@ -5,7 +5,7 @@ - + ngx-theme-stack — Powerful Angular Theme Management @@ -50,6 +50,7 @@
+
diff --git a/projects/demo-ngx-theme-stack/src/themes.css b/projects/demo-ngx-theme-stack/src/themes.css index 3ca75bb..232a52d 100644 --- a/projects/demo-ngx-theme-stack/src/themes.css +++ b/projects/demo-ngx-theme-stack/src/themes.css @@ -88,3 +88,31 @@ --btn-shadow: rgba(255, 165, 0, 0.4); --info-icon: var(--color-white); } + +.sepia { + --bg-dir: to right; + --bg-from: #f5f0e1; + --bg-via: #e8dcc8; + --bg-to: #d4c5a0; + + --sun-transform: translateY(0); + --sun-opacity: 0.6; + --moon-transform: translateY(10rem); + --moon-opacity: 0; + --stars-opacity: 0; + --clouds-opacity: 0.3; + + --card-bg: rgba(139, 109, 68, 0.08); + --card-border: rgba(139, 109, 68, 0.2); + --divider: rgba(139, 109, 68, 0.1); + --tag-bg: rgba(139, 109, 68, 0.1); + --tag-text: #8b6d44; + --text-main: #3d2e1a; + --text-muted: #7a6a52; + --nav-bg: rgba(245, 240, 225, 0.7); + --nav-active: rgba(139, 109, 68, 0.15); + --btn-bg: #3d2e1a; + --btn-text: #f5f0e1; + --btn-shadow: rgba(61, 46, 26, 0.3); + --info-icon: #8b6d44; +} diff --git a/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts b/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts index 7d6c0d4..e15c7f4 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts @@ -28,6 +28,7 @@ export function patchIndexHtml( defaultTheme: string; mode: string; themes: string[]; + schemeMap?: Record; strategy: 'critters' | 'blocking'; }, ): void { @@ -46,6 +47,7 @@ export function patchIndexHtml( defaultTheme: options.defaultTheme, mode: options.mode, themes: options.themes, + schemeMap: options.schemeMap, })}`; let updated = content; diff --git a/projects/ngx-theme-stack/schematics/ng-add/index.ts b/projects/ngx-theme-stack/schematics/ng-add/index.ts index b573b70..4db50d0 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/index.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/index.ts @@ -11,6 +11,7 @@ interface SchematicConfig { storageKey: string; mode: string; themes: string[]; + schemeMap: Record; strategy: 'critters' | 'blocking'; provideCall: string; addSkill: boolean; @@ -56,10 +57,34 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise = {}; + const customOnly = themes.filter((t) => t !== 'light' && t !== 'dark' && t !== 'system'); + if (customOnly.length > 0) { + const wantScheme = await ask( + rl, + ` Do your custom themes need explicit color-scheme hints? [y/N] `, + ); + if (wantScheme.toLowerCase() === 'y') { + for (const name of customOnly) { + const rawScheme = await ask( + rl, + ` color-scheme for "${name}" (light|dark|auto|none, Enter=auto): `, + ); + const s = rawScheme.trim(); + if (s && s !== 'auto') { + schemeMap[name] = s; + } + } + } + } + + const provideCall = buildProvideCall( + defaultTheme, storageKey, mode, themes, strategy, schemeMap, + ); process.stdout.write('\n'); - return { defaultTheme, storageKey, mode, themes, strategy, provideCall, addSkill }; + return { defaultTheme, storageKey, mode, themes, schemeMap, strategy, provideCall, addSkill }; } finally { rl.close(); } @@ -101,6 +126,7 @@ export function ngAdd(options: Schema): Rule { storageKey, mode, themes, + schemeMap: {}, strategy: strategy as 'critters' | 'blocking', provideCall: buildProvideCall(defaultTheme, storageKey, mode, themes, strategy), addSkill: options.addSkill ?? false, @@ -238,6 +264,7 @@ export function ngAdd(options: Schema): Rule { defaultTheme: config.defaultTheme, mode: config.mode, themes: config.themes, + schemeMap: config.schemeMap, strategy: config.strategy, }); changeset.push(' \u001b[33mM\u001b[0m index.html (injected anti-flash)'); diff --git a/projects/ngx-theme-stack/schematics/ng-add/utils.ts b/projects/ngx-theme-stack/schematics/ng-add/utils.ts index a64f710..04f7ef4 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/utils.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/utils.ts @@ -38,8 +38,16 @@ export function buildProvideCall( mode: string, themes: string[], strategy: string, + schemeMap?: Record, ): string { - const themesArr = themes.map((t) => `'${t}'`).join(', '); + const entries = themes.map((t) => { + const scheme = schemeMap?.[t]; + if (scheme) { + return `{ name: '${t}', scheme: '${scheme}' }`; + } + return `'${t}'`; + }); + const themesArr = entries.join(', '); return [ 'provideThemeStack({', ` themes: [${themesArr}] as const,`, diff --git a/projects/ngx-theme-stack/schematics/sync/index.ts b/projects/ngx-theme-stack/schematics/sync/index.ts index 6bfffb4..4f13e9e 100644 --- a/projects/ngx-theme-stack/schematics/sync/index.ts +++ b/projects/ngx-theme-stack/schematics/sync/index.ts @@ -61,6 +61,7 @@ interface ExtractedConfig { mode: string; strategy?: string; themes: string[]; + schemeMap?: Record; } // ── Config extraction ───────────────────────────────────────────────────────── @@ -107,7 +108,21 @@ function extractConfig( .filter(Boolean) : [...DEFAULTS.themes]; - return { mode, strategy, storageKey, defaultTheme, themes }; + // Detect object-form theme entries with scheme hints. + // Matches patterns like: { name: 'sepia', scheme: 'light' } + const schemeMap: Record = {}; + const objThemeRe = /\{\s*name\s*:\s*['"]([^'"]+)['"]\s*,?\s*scheme\s*:\s*['"]([^'"]+)['"]\s*\}/g; + let m: RegExpExecArray | null; + while ((m = objThemeRe.exec(themesRaw)) !== null) { + schemeMap[m[1]] = m[2]; + } + + // Ensure themes from object form are included in the themes list. + for (const name of Object.keys(schemeMap)) { + if (!themes.includes(name)) themes.push(name); + } + + return { mode, strategy, storageKey, defaultTheme, themes, schemeMap: Object.keys(schemeMap).length ? schemeMap : undefined }; } // Fallback to defaults if no config file found @@ -116,6 +131,7 @@ function extractConfig( storageKey: DEFAULTS.storageKey, defaultTheme: DEFAULTS.defaultTheme, themes: [...DEFAULTS.themes], + schemeMap: undefined, }; } @@ -226,7 +242,10 @@ export function sync(options: Schema): Rule { const changeset: string[] = []; // Ensure provideThemeStack exists and is up to date in app.config.ts - const provideCall = buildProvideCall(config.defaultTheme, config.storageKey, config.mode, config.themes, strategy); + const provideCall = buildProvideCall( + config.defaultTheme, config.storageKey, config.mode, + config.themes, strategy, config.schemeMap, + ); await patchAppConfig(tree, context, sourceRoot, provideCall, projectName); context.logger.info(''); @@ -245,7 +264,13 @@ export function sync(options: Schema): Rule { let changed = false; if (content.includes('ngx-theme-stack anti-flash')) { - const newScriptBlock = `\n `; + const newScriptBlock = `\n `; if (SCRIPT_BLOCK_RE.test(content)) { const updatedScript = content.replace(SCRIPT_BLOCK_RE, newScriptBlock); if (updatedScript !== content) { diff --git a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts index 2d4a90a..214f275 100644 --- a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts +++ b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts @@ -3,6 +3,7 @@ export interface AntiFlashScriptOptions { defaultTheme: string; mode: string; themes: string[]; + schemeMap?: Record; } /** @@ -11,9 +12,17 @@ export interface AntiFlashScriptOptions { * * This is the single source of truth for the anti-flash script logic, shared * between ng-add and sync schematics. + * + * When `schemeMap` is provided (theme → color-scheme hint), the script also + * sets the `color-scheme` hint on the root element so that native UI widgets + * (scrollbars, inputs) match the theme even before Angular boots. */ export function buildAntiFlashScript(options: AntiFlashScriptOptions): string { - const { storageKey, defaultTheme, mode, themes } = options; + const { storageKey, defaultTheme, mode, themes, schemeMap } = options; + + const schemeLookup = schemeMap + ? `s=${JSON.stringify(schemeMap)},` + : ''; return ( `(function(){try{` + @@ -21,13 +30,16 @@ export function buildAntiFlashScript(options: AntiFlashScriptOptions): string { `d=${JSON.stringify(defaultTheme)},` + `m=${JSON.stringify(mode)},` + `v=${JSON.stringify(themes)},` + + schemeLookup + `t=localStorage.getItem(k)||d,` + `e=document.documentElement;` + `if(!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)||v.indexOf(t)===-1)t=d;` + `if(t==='system')t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';` + `if(m==='class'||m==='both')e.classList.add(t);` + `if(m==='attribute'||m==='both')e.setAttribute('data-theme',t);` + - `if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);` + + (schemeMap + ? `var sc=(s&&s[t])||'';if(sc==='auto'||sc==='none'){e.style.removeProperty('color-scheme');}else{e.style.setProperty('color-scheme',sc);}` + : `if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);`) + `}catch(x){}})();` ); } diff --git a/projects/ngx-theme-stack/src/lib/config/index.spec.ts b/projects/ngx-theme-stack/src/lib/config/index.spec.ts index 927581f..f4e67f0 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.spec.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.spec.ts @@ -10,7 +10,8 @@ describe('provideThemeStack', () => { }); const config = TestBed.inject(NGX_THEME_STACK_CONFIG); - expect(config).toEqual(DEFAULT_NG_CONFIG); + expect(config.themes).toEqual(DEFAULT_NG_CONFIG.themes); + expect(config.resolvedThemes).toEqual(DEFAULT_NG_CONFIG.resolvedThemes); }); it('should merge custom themes with default themes', () => { @@ -26,6 +27,57 @@ describe('provideThemeStack', () => { // Default themes: ['system', 'light', 'dark'] // Merged: default themes + custom themes (avoiding duplicates) expect(config.themes).toEqual(['system', 'light', 'dark', 'sepia', 'ocean']); + // Custom themes get implicit 'auto' scheme + expect(config.resolvedThemes).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'auto' }, + { name: 'ocean', scheme: 'auto' }, + ]); + }); + + it('should normalize object-form themes with explicit scheme', () => { + TestBed.configureTestingModule({ + providers: [ + provideThemeStack({ + themes: [ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ], + }), + ], + }); + + const config = TestBed.inject(NGX_THEME_STACK_CONFIG); + expect(config.themes).toEqual(['system', 'light', 'dark', 'sepia', 'ocean']); + expect(config.resolvedThemes).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ]); + }); + + it('should mix string and object-form themes', () => { + TestBed.configureTestingModule({ + providers: [ + provideThemeStack({ + themes: ['forest', { name: 'sand', scheme: 'light' }], + }), + ], + }); + + const config = TestBed.inject(NGX_THEME_STACK_CONFIG); + expect(config.themes).toEqual(['system', 'light', 'dark', 'forest', 'sand']); + expect(config.resolvedThemes).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'forest', scheme: 'auto' }, + { name: 'sand', scheme: 'light' }, + ]); }); it('should throw an error if a theme name is empty or whitespace', () => { @@ -42,6 +94,14 @@ describe('provideThemeStack', () => { ).toThrow('Theme cannot be empty or whitespace.'); }); + it('should throw an error if an object-form theme name is empty', () => { + expect(() => + provideThemeStack({ + themes: [{ name: ' ' }], + }) + ).toThrow('Theme cannot be empty or whitespace.'); + }); + it('should throw an error if defaultTheme is not in the resolved themes list', () => { expect(() => provideThemeStack({ diff --git a/projects/ngx-theme-stack/src/lib/config/index.ts b/projects/ngx-theme-stack/src/lib/config/index.ts index 4a3c51c..3d64ff2 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.ts @@ -1,6 +1,14 @@ import { InjectionToken } from '@angular/core'; import { NgxThemeStackError } from '../errors'; -import { DEFAULT_THEMES, DefaultNgTheme, NgConfig } from '../types'; +import { + DEFAULT_THEMES, + DefaultNgTheme, + NgConfig, + normalizeThemeInputs, + NgTheme, + NgThemeInput, + NgThemeOption, +} from '../types'; /** * ⚠ ATTENTION: SHARED CONFIGURATION VALUES @@ -15,13 +23,14 @@ import { DEFAULT_THEMES, DefaultNgTheme, NgConfig } from '../types'; * schematics/ng-add/constants.ts → DEFAULTS + DEFAULT_THEMES */ -export const DEFAULT_NG_CONFIG = { +export const DEFAULT_NG_CONFIG: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', strategy: 'critters', - themes: [...DEFAULT_THEMES], -} satisfies NgConfig; + themes: [...DEFAULT_THEMES] as Array, + resolvedThemes: normalizeThemeInputs(DEFAULT_THEMES as unknown as NgThemeInput[]), +}; // The token uses NgConfig because Angular DI resolves types at runtime // and cannot carry generic parameters. Type-safety is enforced at the @@ -91,15 +100,36 @@ export const NGX_THEME_STACK_CONFIG = new InjectionToken>( export function provideThemeStack( config: Partial> = {}, ) { - config.themes?.forEach((t) => { - if (t.trim() === '') throw new NgxThemeStackError('Theme cannot be empty or whitespace.'); - }); + // Validate user-supplied themes: any entry (string or object form) must + // carry a non-empty, trimmed name. + if (config.themes) { + for (const entry of config.themes as NgThemeInput[]) { + const name = typeof entry === 'string' ? entry : entry.name; + if (name.trim() === '') { + throw new NgxThemeStackError('Theme cannot be empty or whitespace.'); + } + } + } + + // Resolve built-in + user themes into a single list of names. + const builtinNames: string[] = [...DEFAULT_NG_CONFIG.themes].map((t) => + typeof t === 'string' ? t : t.name, + ); + const userNames: string[] = config.themes + ? (config.themes as NgThemeInput[]).map((t) => + typeof t === 'string' ? t.trim() : t.name.trim(), + ) + : []; + const themes: string[] = Array.from(new Set([...builtinNames, ...userNames])); - const themes = config.themes - ? Array.from(new Set([...DEFAULT_NG_CONFIG.themes, ...config.themes])) - : DEFAULT_NG_CONFIG.themes; + // Normalize to ResolvedTheme[] (with per-theme scheme hints). + const allInputs: NgThemeInput[] = [ + ...DEFAULT_NG_CONFIG.themes, + ...(config.themes ?? []), + ]; + const resolvedThemes = normalizeThemeInputs(allInputs); - if (config.defaultTheme && !(themes as string[]).includes(config.defaultTheme as string)) { + if (config.defaultTheme && !themes.includes(config.defaultTheme as string)) { throw new NgxThemeStackError( `"defaultTheme" must be one of the resolved themes: [${themes.join(', ')}].`, ); @@ -114,7 +144,10 @@ export function provideThemeStack( useValue: { ...DEFAULT_NG_CONFIG, ...config, - themes, + themes: config.themes + ? (config.themes as unknown as NgTheme[]) + : (DEFAULT_NG_CONFIG.themes as unknown as NgTheme[]), + resolvedThemes, } as NgConfig, }; } diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts new file mode 100644 index 0000000..c38ffbb --- /dev/null +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts @@ -0,0 +1,134 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CoreThemeService } from './core-theme.service'; +import { NGX_THEME_STACK_CONFIG } from '../config'; +import { NgConfig, normalizeThemeInputs, NgThemeInput } from '../types'; + +function setup( + config: Partial = {}, + initialStore: Record = {}, + systemPrefersDark = false, + platformId = 'browser', +) { + let store: Record = { ...initialStore }; + + vi.stubGlobal('localStorage', { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + clear: () => { + store = {}; + }, + }); + + const matchMediaMock = { + matches: systemPrefersDark, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(matchMediaMock)); + + const themes = (config.themes as NgThemeInput[] | undefined) ?? ['light', 'dark', 'system']; + const fullConfig: NgConfig = { + defaultTheme: 'system', + storageKey: 'ngx-theme-stack', + mode: 'class', + themes, + strategy: 'critters', + resolvedThemes: normalizeThemeInputs(themes as NgThemeInput[]), + ...config, + } as NgConfig; + + TestBed.configureTestingModule({ + providers: [ + CoreThemeService, + { provide: NGX_THEME_STACK_CONFIG, useValue: fullConfig }, + { provide: PLATFORM_ID, useValue: platformId }, + ], + }); + + const service = TestBed.inject(CoreThemeService); + return { service, matchMediaMock }; +} + +describe('CoreThemeService — per-theme color-scheme hints', () => { + afterEach(() => { + vi.restoreAllMocks(); + const host = document.documentElement; + host.className = ''; + host.removeAttribute('data-theme'); + host.style.removeProperty('color-scheme'); + }); + + it('applies the user-declared scheme for a custom theme', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'sepia', scheme: 'light' } as NgThemeInput], + }); + service.setTheme('sepia'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('light'); + }); + + it('applies dark scheme for a custom theme', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'ocean', scheme: 'dark' } as NgThemeInput], + }); + service.setTheme('ocean'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + }); + + it('removes color-scheme when custom theme scheme is auto', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'forest' } as NgThemeInput], + }); + service.setTheme('dark'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + + service.setTheme('forest'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); + }); + + it('removes color-scheme when custom theme scheme is none', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'paper', scheme: 'none' } as NgThemeInput], + }); + service.setTheme('dark'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + + service.setTheme('paper'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); + }); + + it('keeps the implicit hint for built-in light/dark', () => { + const { service } = setup({ themes: ['light', 'dark', 'system'] }); + service.setTheme('light'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('light'); + + service.setTheme('dark'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + }); + + it('accepts object-form themes and validates setTheme on the name', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'moss', scheme: 'dark' } as NgThemeInput], + }); + service.setTheme('moss'); + expect(service.selectedTheme()).toBe('moss'); + expect(() => service.setTheme('nope')).toThrow('Invalid theme'); + }); + + it('exposes themeNames as a plain string array on select/cycle', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'sepia', scheme: 'light' } as NgThemeInput], + }); + expect(service.themeNames).toEqual(['light', 'dark', 'system', 'sepia']); + }); +}); diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts index a6ce270..6f237da 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts @@ -1,9 +1,10 @@ import { Component, PLATFORM_ID, ChangeDetectionStrategy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NGX_THEME_STACK_CONFIG } from '../config'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs } from '../types'; import { CoreThemeService } from './core-theme.service'; + @Component({ template: '', changeDetection: ChangeDetectionStrategy.OnPush, @@ -58,6 +59,7 @@ function setup( mode: 'class', themes: ['light', 'dark', 'system'], strategy: 'critters', + resolvedThemes: normalizeThemeInputs(['light', 'dark', 'system']), ...config, }; diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts index b97cea8..aad6752 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { NgxThemeStackError } from '../errors'; -import { NgSystemTheme, NgTheme } from '../types'; +import { NgSystemTheme, NgTheme, ResolvedTheme } from '../types'; /** * Core service for managing the application's color theme. @@ -37,8 +37,21 @@ export class CoreThemeService { /** List of available themes for Select/Cycle services. Defaults to ['system', 'light', 'dark']. */ readonly availableThemes = this.#config.themes; - /** Internal Set for O(1) existence checks. */ - readonly #validThemes = new Set(this.availableThemes); + /** Normalized themes with per-theme color-scheme hints. */ + readonly resolvedThemes: ResolvedTheme[] = this.#config.resolvedThemes; + + /** Plain string array of theme names, suitable for template binding. */ + readonly themeNames: string[] = this.resolvedThemes.map((t) => t.name); + + /** O(1) name→scheme lookup. */ + readonly #schemeByName: ReadonlyMap = new Map( + this.resolvedThemes.map((t) => [t.name, t.scheme]), + ); + + /** Internal Set for O(1) existence checks. Built from normalized names. */ + readonly #validThemes = new Set( + this.resolvedThemes.map((t) => t.name as NgTheme), + ); /** * The anti-flash class to remove from the host element. @@ -210,17 +223,20 @@ export class CoreThemeService { } private applyThemeClasses(host: HTMLElement, theme: NgTheme): void { - host.classList.remove(...this.availableThemes); + host.classList.remove(...this.resolvedThemes.map((t) => t.name)); host.classList.add(theme); } private applyColorSchemeHint(host: HTMLElement, theme: NgTheme): void { - if (theme === 'dark' || theme === 'light') { - host.style.setProperty('color-scheme', theme); + // 'system' never reaches this path — it is resolved to 'dark' or 'light' + // by `resolvedTheme`. Custom themes carry the user-declared scheme hint + // (default: 'auto', no change). + const scheme = this.#schemeByName.get(theme) ?? 'auto'; + if (scheme === 'auto' || scheme === 'none') { + host.style.removeProperty('color-scheme'); return; } - - host.style.removeProperty('color-scheme'); + host.style.setProperty('color-scheme', scheme); } private captureAntiFlashClass(): void { diff --git a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts index f032097..8370fa9 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { ThemeCycleService } from './theme-cycle.service'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { CoreThemeService } from '../core/core-theme.service'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs, NgThemeInput } from '../types'; function setup(config: Partial = {}) { let store: Record = {}; @@ -20,14 +20,16 @@ function setup(config: Partial = {}) { removeEventListener: vi.fn(), })); + const themes = (config.themes as NgThemeInput[] | undefined) ?? ['light', 'dark', 'system']; const fullConfig: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', - themes: ['light', 'dark', 'system'], + themes, strategy: 'critters', + resolvedThemes: normalizeThemeInputs(themes as NgThemeInput[]), ...config, - }; + } as NgConfig; TestBed.configureTestingModule({ providers: [ diff --git a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts index f57d176..7950218 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts @@ -1,5 +1,6 @@ import { computed, inject, Injectable } from '@angular/core'; import { CoreThemeService } from '../core/core-theme.service'; +import { NgTheme } from '../types'; /** * Convenience service for cycling through themes in a fixed order. @@ -13,8 +14,15 @@ import { CoreThemeService } from '../core/core-theme.service'; export class ThemeCycleService { readonly #core = inject(CoreThemeService); + /** + * List of all configured theme **names** for cycling. + * Returns a plain `string[]` so template consumers can bind directly. + * Defaults to `['system', 'light', 'dark']` plus any user-declared themes. + */ + readonly themeNames: string[] = this.#core.themeNames; + /** List of all configured themes for cycling. Defaults to `['light', 'dark', 'system']`. */ - readonly availableThemes = this.#core.availableThemes; + readonly availableThemes = this.themeNames as NgTheme[]; /** The theme explicitly selected by the user. May be `'system'`. */ readonly selectedTheme = this.#core.selectedTheme; @@ -24,20 +32,20 @@ export class ThemeCycleService { /** Index of the currently selected theme in the cycle. */ readonly cycleIndex = computed(() => { - return this.availableThemes.indexOf(this.selectedTheme()); + return this.themeNames.indexOf(this.selectedTheme()); }); /** The theme that comes before the currently selected theme in the cycle. */ readonly preceding = computed(() => { const index = this.cycleIndex(); - const len = this.availableThemes.length; - return this.availableThemes[(index - 1 + len) % len]; + const len = this.themeNames.length; + return this.themeNames[(index - 1 + len) % len]; }); /** The theme that comes after the currently selected theme in the cycle. */ readonly upcoming = computed(() => { const index = this.cycleIndex(); - return this.availableThemes[(index + 1) % this.availableThemes.length]; + return this.themeNames[(index + 1) % this.themeNames.length]; }); /** Whether the currently applied theme is `'dark'`. */ diff --git a/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts b/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts index c0d2251..a0cd002 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { ThemeSelectService } from './theme-select.service'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { CoreThemeService } from '../core/core-theme.service'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs, NgThemeInput } from '../types'; function setup(config: Partial = {}) { let store: Record = {}; @@ -20,14 +20,16 @@ function setup(config: Partial = {}) { removeEventListener: vi.fn(), })); + const themes = (config.themes as NgThemeInput[] | undefined) ?? ['light', 'dark', 'system']; const fullConfig: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', - themes: ['light', 'dark', 'system'], + themes, strategy: 'critters', + resolvedThemes: normalizeThemeInputs(themes as NgThemeInput[]), ...config, - }; + } as NgConfig; TestBed.configureTestingModule({ providers: [ diff --git a/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts b/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts index cb1990d..61b69e9 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts @@ -12,8 +12,15 @@ import { NgTheme } from '../types'; export class ThemeSelectService { readonly #core = inject(CoreThemeService); + /** + * List of all configured theme **names** for select UI. + * Returns a plain `string[]` so template consumers can bind directly. + * Defaults to `['system', 'light', 'dark']`. + */ + readonly themeNames: string[] = this.#core.themeNames; + /** List of all configured themes. Defaults to `['light', 'dark', 'system']`. */ - readonly availableThemes = this.#core.availableThemes; + readonly availableThemes = this.themeNames as NgTheme[]; /** The theme explicitly selected by the user. May be `'system'`. */ readonly selectedTheme = this.#core.selectedTheme; diff --git a/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts b/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts index b847f54..31551c9 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts @@ -2,7 +2,7 @@ import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { CoreThemeService } from '../core/core-theme.service'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs } from '../types'; import { ThemeToggleService } from './theme-toggle.service'; function setup(systemPrefersDark = false) { @@ -33,6 +33,7 @@ function setup(systemPrefersDark = false) { mode: 'class', themes: ['light', 'dark', 'system'], strategy: 'critters', + resolvedThemes: normalizeThemeInputs(['light', 'dark', 'system']), }; TestBed.configureTestingModule({ diff --git a/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts new file mode 100644 index 0000000..9dc01f1 --- /dev/null +++ b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts @@ -0,0 +1,86 @@ +import { + implicitScheme, + normalizeThemeInputs, +} from './types'; + +describe('normalizeThemeInputs', () => { + it('normalizes string entries with implicit scheme', () => { + const result = normalizeThemeInputs(['light', 'dark', 'sepia']); + expect(result).toEqual([ + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'auto' }, + ]); + }); + + it('deduplicates entries by name (first wins)', () => { + const result = normalizeThemeInputs([ + 'light', + { name: 'light', scheme: 'dark' }, + 'light', + ]); + expect(result).toEqual([{ name: 'light', scheme: 'light' }]); + }); + + it('handles object entries with explicit scheme', () => { + const result = normalizeThemeInputs([ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ]); + expect(result).toEqual([ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ]); + }); + + it('defaults object entries to auto when scheme is omitted', () => { + const result = normalizeThemeInputs([{ name: 'forest' }]); + expect(result).toEqual([{ name: 'forest', scheme: 'auto' }]); + }); + + it('ignores empty-string entries', () => { + const result = normalizeThemeInputs(['', ' ', 'dark']); + expect(result).toEqual([{ name: 'dark', scheme: 'dark' }]); + }); + + it('ignores object entries with empty names', () => { + const result = normalizeThemeInputs([{ name: ' ' }, 'dark']); + expect(result).toEqual([{ name: 'dark', scheme: 'dark' }]); + }); + + it('mixes string and object entries', () => { + const result = normalizeThemeInputs([ + 'system', + 'light', + 'dark', + { name: 'sepia', scheme: 'light' }, + ]); + expect(result).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'light' }, + ]); + }); + + it('trims whitespace in names', () => { + const result = normalizeThemeInputs([' ocean ', ' reef']); + expect(result).toEqual([ + { name: 'ocean', scheme: 'auto' }, + { name: 'reef', scheme: 'auto' }, + ]); + }); +}); + +describe('implicitScheme', () => { + it('maps light/dark to their matching scheme', () => { + expect(implicitScheme('light')).toBe('light'); + expect(implicitScheme('dark')).toBe('dark'); + }); + + it('returns auto for all other names', () => { + expect(implicitScheme('system')).toBe('auto'); + expect(implicitScheme('sepia')).toBe('auto'); + expect(implicitScheme('ocean')).toBe('auto'); + }); +}); diff --git a/projects/ngx-theme-stack/src/lib/types.ts b/projects/ngx-theme-stack/src/lib/types.ts index 78aacb8..652845b 100644 --- a/projects/ngx-theme-stack/src/lib/types.ts +++ b/projects/ngx-theme-stack/src/lib/types.ts @@ -36,6 +36,104 @@ export type NgTheme = DefaultNgTheme | T; */ export type NgSystemTheme = Exclude; +/** + * Color scheme hint applied to the root element via `color-scheme`. + * + * - `'light'` / `'dark'` — forces the browser to use matching native UI widgets. + * - `'auto'` — no explicit hint; the browser chooses based on surrounding styles. + * - `'none'` — explicitly removes any inherited `color-scheme` (legacy default). + * + * This value is forwarded to `document.documentElement.style.color-scheme`. + * It only affects how the browser paints native widgets (scrollbars, inputs, + * form controls) in that color scheme — it does not pick a theme for you. + */ +export type NgColorScheme = 'light' | 'dark' | 'auto' | 'none'; + +/** + * Declarative theme entry. + * + * A theme can be declared as a plain string (no color-scheme hint) or as an + * object that pairs a name with a color-scheme hint. + * + * @example + * ```ts + * provideThemeStack({ + * themes: ['system', 'light', 'dark', { name: 'sepia', scheme: 'light' }], + * }); + * ``` + */ +export interface NgThemeOption { + /** Theme identifier. Must match `/^[a-zA-Z][a-zA-Z0-9_-]*$/`. */ + name: string; + /** + * Optional color-scheme hint for native UI widgets when this theme is active. + * - Omitted (or `'auto'`): the library will not set a `color-scheme` hint. + * - `'light'` / `'dark'`: hint is set on the root element while the theme is active. + * - `'none'`: explicitly removed. + */ + scheme?: NgColorScheme; +} + +/** + * Single entry in a `themes` array. Accepts either a plain string or a + * {@link NgThemeOption} object. + */ +export type NgThemeInput = string | NgThemeOption; + +/** + * Normalized internal representation of every theme, regardless of the + * user-facing input shape. + */ +export interface ResolvedTheme { + /** Theme identifier, validated to be a valid CSS identifier. */ + name: string; + /** Color-scheme hint to apply. Defaults to `'auto'` when omitted. */ + scheme: NgColorScheme; +} + +/** + * Normalizes a `themes` input (string or object form) into `ResolvedTheme[]`. + * + * - String entries are treated as scheme-less and default to `'auto'`. + * - Object entries must have a non-empty `name` and a valid `scheme`. + * - Built-in themes `'system'`, `'light'`, `'dark'` are special-cased: + * `'light'` and `'dark'` carry an implicit scheme of their own name; + * `'system'` is scheme-less (resolved via `matchMedia` at runtime). + */ +export function normalizeThemeInputs(themes: readonly NgThemeInput[]): ResolvedTheme[] { + const seen = new Set(); + const out: ResolvedTheme[] = []; + for (const entry of themes) { + if (typeof entry === 'string') { + const name = entry.trim(); + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, scheme: implicitScheme(name) }); + } else { + const name = entry.name.trim(); + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, scheme: entry.scheme ?? 'auto' }); + } + } + return out; +} + +/** + * Returns the implicit color-scheme hint for a built-in theme name. + * + * - `'light'` → `'light'`, `'dark'` → `'dark'` (matching CSS `color-scheme`). + * - `'system'` → `'auto'` (resolved at runtime via `matchMedia`). + * - any other name → `'auto'` (no hint; the browser picks). + */ +export function implicitScheme(name: string): NgColorScheme { + if (name === 'light') return 'light'; + if (name === 'dark') return 'dark'; + return 'auto'; +} + /** * Theme application mode. * - `'attribute'`: sets `data-theme` attribute on `` @@ -79,20 +177,41 @@ export interface NgConfig { strategy: NgStrategy; /** - * The **resolved** list of supported theme identifiers, always including the - * built-in themes (`'light'`, `'dark'`, `'system'`). + * The list of supported theme identifiers. + * + * Accepts two input shapes (mixable in one array): + * - **String**: plain theme name. No color-scheme hint is applied for custom + * themes; built-in names keep their implicit hint (`light`/`dark`). + * - **Object**: `{ name, scheme? }`. Use when a custom theme needs an + * explicit `color-scheme` hint so native UI widgets match the theme. * - * When you pass custom themes to {@link provideThemeStack}, they are **merged** - * with the built-in defaults — your custom values are appended after them. + * The built-in themes (`'light'`, `'dark'`, `'system'`) are **always + * included** in the resolved list even if not in your array. Your entries + * are appended after them. * * @example - * // Input to provideThemeStack: + * // Legacy: string-only (works, no hints on custom themes) * themes: ['sepia', 'ocean'] as const * - * // Resolved value stored in NgConfig: - * // ['system', 'light', 'dark', 'sepia', 'ocean'] + * @example + * // New: object form with scheme hints + * themes: [ + * { name: 'sepia', scheme: 'light' }, + * { name: 'ocean', scheme: 'dark' }, + * ] as const * - * Default (no custom themes): `['system', 'light', 'dark']`. + * Resolved value stored in `resolvedThemes` (see {@link NgConfig.resolvedThemes}): + * // [{ name: 'system', scheme: 'auto' }, { name: 'light', scheme: 'light' }, + * // { name: 'dark', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }, + * // { name: 'ocean', scheme: 'dark' }] + */ + themes: Array | NgThemeOption>; + + /** + * Internal, normalized view of {@link NgConfig.themes}. + * Populated by {@link normalizeThemeInputs}; read-only for consumers. + * The public `themes` field is user-facing, while `resolvedThemes` carries + * the parsed scheme hints. */ - themes: NgTheme[]; + resolvedThemes: ResolvedTheme[]; } diff --git a/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts b/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts new file mode 100644 index 0000000..6735352 --- /dev/null +++ b/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts @@ -0,0 +1,52 @@ +import { buildAntiFlashScript } from '../../schematics/utils/anti-flash-script'; + +describe('buildAntiFlashScript', () => { + it('sets color-scheme for dark/light without schemeMap (legacy behavior)', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'system', + mode: 'class', + themes: ['system', 'light', 'dark'], + }); + expect(script).toContain( + "if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);", + ); + }); + + it('uses schemeMap when provided', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'sunset', + mode: 'class', + themes: ['system', 'light', 'dark', 'sunset', 'sepia'], + schemeMap: { sunset: 'dark', sepia: 'light' }, + }); + expect(script).toContain('s={"sunset":"dark","sepia":"light"}'); + expect(script).toContain('sc=(s&&s[t])'); + expect(script).toContain("e.style.removeProperty('color-scheme')"); + }); + + it('includes custom themes in the valid list', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'sepia', + mode: 'class', + themes: ['system', 'light', 'dark', 'sepia'], + schemeMap: { sepia: 'light' }, + }); + expect(script).toContain('v=["system","light","dark","sepia"]'); + }); + + it('removes color-scheme when scheme is auto or none', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'system', + mode: 'class', + themes: ['system', 'light', 'dark'], + schemeMap: { sepia: 'auto' }, + }); + expect(script).toContain( + "if(sc==='auto'||sc==='none'){e.style.removeProperty('color-scheme');}", + ); + }); +}); diff --git a/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts b/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts index 5264695..7eec0ad 100644 --- a/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts +++ b/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts @@ -1,6 +1,7 @@ import { HostTree } from '@angular-devkit/schematics'; import { assertAngularProject, + buildProvideCall, detectPackageManager, looksLikeNestMain, } from '../../schematics/ng-add/utils'; @@ -55,6 +56,37 @@ describe('assertAngularProject', () => { }); }); +describe('buildProvideCall', () => { + it('renders string themes as plain quotes', () => { + const call = buildProvideCall('system', 'key', 'class', ['system', 'light', 'dark'], 'critters'); + expect(call).toContain("themes: ['system', 'light', 'dark'] as const"); + expect(call).not.toContain('scheme'); + }); + + it('renders object-form entries when schemeMap is provided', () => { + const call = buildProvideCall( + 'system', 'key', 'class', + ['system', 'light', 'dark', 'sepia'], + 'critters', + { sepia: 'light' }, + ); + expect(call).toContain("{ name: 'sepia', scheme: 'light' }"); + expect(call).toContain("'system'"); + expect(call).toContain("'light'"); + }); + + it('leaves entries without a schemeMap entry as plain strings', () => { + const call = buildProvideCall( + 'system', 'key', 'class', + ['system', 'light', 'dark', 'sepia', 'ocean'], + 'critters', + { sepia: 'light' }, + ); + expect(call).toContain("{ name: 'sepia', scheme: 'light' }"); + expect(call).toContain("'ocean'"); + }); +}); + describe('detectPackageManager', () => { it('reads the packageManager field', () => { const tree = new HostTree(); From 15622eabd861ebbdb2ecd72ac7e7750fcc102c34 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 09:08:29 -0500 Subject: [PATCH 02/15] docs(lib): document per-theme color-scheme hints Adds the public API reference for the object-form theme entries ({ name, scheme? }) and the NgColorScheme union, plus a table of scheme values and their effect on the root element's color-scheme. --- projects/ngx-theme-stack/README.md | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/projects/ngx-theme-stack/README.md b/projects/ngx-theme-stack/README.md index 7044506..3f81a5e 100644 --- a/projects/ngx-theme-stack/README.md +++ b/projects/ngx-theme-stack/README.md @@ -27,6 +27,7 @@ Built for performance, signal-driven reactivity, and SSR zero-flash support. - **🧱 Angular Signals:** Built natively with reactive signals for maximum performance. - **🌍 SSR & Hydration Ready:** Guaranteed zero flicker or layout shift on startup. - **🔄 Extensible & Custom Themes:** Seamlessly add sepia, sunset, or custom color palettes. +- **🎛️ Per-Theme Color Scheme Hints:** Declara el `color-scheme` para cada custom theme y deja que los widgets nativos del browser (scrollbars, inputs) coincidan con el tema activo. - **🤖 AI Code Assistants Integration:** Work with AI coding assistants using the generated `SKILL.md` standard. --- @@ -94,6 +95,47 @@ export class ThemeToggle { --- +## 🎛️ Per-Theme Color Scheme Hints + +When you use custom themes, the library can also apply a `color-scheme` hint to the root element so that native UI widgets (scrollbars, form controls) match the active theme. + +```typescript +// Legacy: string form — no hint on custom themes +provideThemeStack({ + themes: ['sepia', 'ocean'] as const, +}) + +// New: object form — explicit hint per custom theme +provideThemeStack({ + themes: [ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ] as const, +}) +``` + +Both shapes can be mixed in the same array: + +```typescript +provideThemeStack({ + themes: ['light', 'dark', 'system', { name: 'forest' }] as const, + // 'light' and 'dark' keep their implicit hint; 'forest' defaults to 'auto' (no hint) +}) +``` + +`scheme` values: + +| Value | Effect on `color-scheme` | +|---------|-------------------------------------| +| `'light'` | Sets `color-scheme: light` on the root element while the theme is active. | +| `'dark'` | Sets `color-scheme: dark`. | +| `'auto'` | Removes the hint; the browser decides. | +| `'none'` | Explicitly removes the hint (legacy default for custom themes). | + +The hint is applied to `` in the runtime and in the anti-flash script (before Angular boots), and is regenerated by the `sync` schematic. + +--- + ## 📚 Advanced Guides & Documentation For details on more advanced topics, check out the official guides: From 5622c07453a8ee623dc8f335e74aed85e1944e33 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 10:16:47 -0500 Subject: [PATCH 03/15] fix(deps): pin jsdom's undici and drop the duplicate Angular copy Two independent problems made `pnpm test` unusable: 1. jsdom@28.1.0 requires undici v7's subpath layout (lib/handler/wrap-handler.js), which undici v8 removed. The workspace hoisted undici@8.11.2 (via release-it), so the vitest worker crashed at startup with MODULE_NOT_FOUND and no test ever ran. Scoped the override to jsdom so release-it keeps undici 8.x. 2. The library declared @angular/core and @angular/common as peerDependencies, so pnpm auto-installed a second copy (@angular/core@22.0.4) under projects/ngx-theme-stack/node_modules while the root had 22.2.1. The Angular unit-test runner initializes TestBed from the root copy, so every spec using TestBed failed with 'Need to call TestBed.initTestEnvironment() first'. Set autoInstallPeers: false so the library resolves Angular from the workspace root (single module instance). Verified: pnpm test goes from a worker crash (0 tests run) to 110 passing; pnpm run build:lib and pnpm run build:demo succeed. --- pnpm-lock.yaml | 452 +++++--------------------------------------- pnpm-workspace.yaml | 15 ++ 2 files changed, 66 insertions(+), 401 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5342be6..83d709d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,7 +1,7 @@ lockfileVersion: '9.0' settings: - autoInstallPeers: true + autoInstallPeers: false excludeLinksFromLockfile: false overrides: @@ -14,6 +14,7 @@ overrides: undici@<6.27.0: '>=6.27.0' undici@>=7.0.0 <7.28.0: '>=7.28.0' undici@>=8.0.0 <8.5.0: '>=8.5.0' + jsdom@28.1.0>undici: ^7.21.0 vite@<=7.3.4: '>=7.3.5' esbuild@<0.28.1: '>=0.28.1' '@babel/core@<=7.29.0': '>=7.29.6' @@ -64,7 +65,7 @@ importers: version: 22.5.0(chokidar@5.0.0)(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) '@angular/build': specifier: ^22.2.1 - version: 22.2.1(0d531d1c0b4397fb59f5dda158b6a676) + version: 22.2.1(780bab2ee8da2e8b36c64fa126dcb6da) '@angular/cli': specifier: ^22.2.1 version: 22.2.1(@types/node@20.19.43)(chokidar@5.0.0) @@ -133,16 +134,10 @@ importers: version: 8.62.1(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@20.19.43)(jsdom@28.1.0)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + version: 4.1.11(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@28.1.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) projects/ngx-theme-stack: dependencies: - '@angular/common': - specifier: ^20.0.0 || ^21.0.0 || ^22.0.0 - version: 22.0.4(@angular/core@22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2))(rxjs@7.8.2) - '@angular/core': - specifier: ^20.0.0 || ^21.0.0 || ^22.0.0 - version: 22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2) tslib: specifier: ^2.3.0 version: 2.8.1 @@ -283,13 +278,6 @@ packages: engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@22.0.4': - resolution: {integrity: sha512-ibHINcvajoXfKfb4aGdLx8aOFkUOadAYF9/Yy4j2C1gQdLS6uXOCPFD9+W9zWf8OeMhL0H+i+5KeNlh6XbGaZw==} - engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - peerDependencies: - '@angular/core': 22.0.4 - rxjs: ^6.5.3 || ^7.4.0 - '@angular/common@22.2.1': resolution: {integrity: sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} @@ -308,27 +296,10 @@ packages: typescript: optional: true - '@angular/compiler@22.0.4': - resolution: {integrity: sha512-PvbsPr3d0OHm90vgq1v+0oPP/oHNuCcZQAhhD3fhHlx3DTxf6nkjUqBulzdJ6d5QwDsy378pwf8+dpGquFzbGQ==} - engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/compiler@22.2.1': resolution: {integrity: sha512-KRSz0c/T5eEiFa+F1l9OppAebJj9pT8kU3ArBMlxR3v/vzhpCfuQsFVQ4xEFC7dEm3zZIdv9ASKAP6ocNC4D/Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@22.0.4': - resolution: {integrity: sha512-QTGwZcnc6dBhGPMID8DvwRQdHMvpqV0vXDX8jSdmVDiWDsFUjbe9AUFTHEizDm+wnmxOrCQ0DHkgmHICuh0GpA==} - engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - peerDependencies: - '@angular/compiler': 22.0.4 - rxjs: ^6.5.3 || ^7.4.0 - zone.js: ~0.15.0 || ~0.16.0 - peerDependenciesMeta: - '@angular/compiler': - optional: true - zone.js: - optional: true - '@angular/core@22.2.1': resolution: {integrity: sha512-a8WlYTRiaMPz1KvLE5xjeWrLyZUuQX8bh6Ak7zajUci9iWsTUQefschjSwP1WKlin3JhM3XjmsTEDyZvSLIfRg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} @@ -426,10 +397,6 @@ packages: resolution: {integrity: sha512-2/oWkgTbBYoqioCWAE4XJobOrzwxTDa5/XjDP3tJ1BhDr/owcd9qnXBp4xc3/2G5X4bvXM04nrxbRJxFcXAxFQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/core@8.0.6': - resolution: {integrity: sha512-5zwYt1V4ji3mXKKRIAkohSJ29WepxNwxQi+7y7P5AcVPh1dcxnZBU2cfxHnPwS+u9+lZnXPsv30TQaF2xg+Tfg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/generator@8.0.6': resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} engines: {node: ^22.18.0 || >=24.11.0} @@ -442,10 +409,6 @@ packages: resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.6': resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} engines: {node: ^22.18.0 || >=24.11.0} @@ -466,11 +429,6 @@ packages: resolution: {integrity: sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/parser@7.29.9': - resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@8.0.6': resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -484,10 +442,6 @@ packages: resolution: {integrity: sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} - engines: {node: '>=6.9.0'} - '@babel/types@8.0.6': resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1069,10 +1023,6 @@ packages: '@types/node': optional: true - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} - engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1790,144 +1740,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.62.2': - resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.2': - resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.2': - resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.2': - resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.2': - resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.2': - resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.62.2': - resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.62.2': - resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.62.2': - resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.62.2': - resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.62.2': - resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.2': - resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.2': - resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.2': - resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} - cpu: [x64] - os: [win32] - '@schematics/angular@22.2.1': resolution: {integrity: sha512-L4Nl8D43znCzXwze9PtbQhG1GKlhWq9ip8Hz+e3LHgG3WpT5Tlr7aVsyHDqb9Qetuy0Mpd4tXE15STtGdA897w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -2425,11 +2237,6 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3460,14 +3267,6 @@ packages: resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} engines: {node: ^18.17 || >=20.6.1} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -4286,11 +4085,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.62.2: - resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4870,6 +4664,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + undici@8.11.2: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} @@ -4990,7 +4788,6 @@ packages: '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' - vite: '>=7.3.5' peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -5183,7 +4980,7 @@ snapshots: '@angular-devkit/core@22.2.1(chokidar@5.0.0)': dependencies: ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) + ajv-formats: 3.0.1 jsonc-parser: 3.3.1 picomatch: 4.0.7 rxjs: 7.8.2 @@ -5264,7 +5061,7 @@ snapshots: eslint: 10.12.0(jiti@2.7.0) typescript: 6.0.3 - '@angular/build@22.2.1(0d531d1c0b4397fb59f5dda158b6a676)': + '@angular/build@22.2.1(780bab2ee8da2e8b36c64fa126dcb6da)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2202.1(chokidar@5.0.0) @@ -5273,7 +5070,7 @@ snapshots: '@babel/core': 8.0.5 '@inquirer/confirm': 6.3.2(@types/node@20.19.43) '@parcel/watcher': 2.6.0 - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1)) beasties: 0.5.4 browserslist: 4.29.3 chokidar: 5.0.0 @@ -5302,14 +5099,12 @@ snapshots: '@angular/platform-browser': 22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)) '@angular/platform-server': 22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.2.1)(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-browser@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)))(rxjs@7.8.2) '@angular/ssr': 22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-server@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.2.1)(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-browser@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)))(rxjs@7.8.2))(@angular/router@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-browser@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)))(rxjs@7.8.2)) - istanbul-lib-instrument: 6.0.3 less: 4.9.1 lmdb: 3.5.6 ng-packagr: 22.2.4(@angular/compiler-cli@22.2.1(@angular/compiler@22.2.1)(typescript@6.0.3))(tailwindcss@4.3.3)(tslib@2.8.1)(typescript@6.0.3) postcss: 8.5.29 - rollup: 4.62.2 tailwindcss: 4.3.3 - vitest: 4.1.11(@types/node@20.19.43)(jsdom@28.1.0)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + vitest: 4.1.11(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@28.1.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -5342,12 +5137,6 @@ snapshots: - '@types/node' - chokidar - '@angular/common@22.0.4(@angular/core@22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2))(rxjs@7.8.2)': - dependencies: - '@angular/core': 22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2) - rxjs: 7.8.2 - tslib: 2.8.1 - '@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: '@angular/core': 22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2) @@ -5368,22 +5157,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@angular/compiler@22.0.4': - dependencies: - tslib: 2.8.1 - optional: true - '@angular/compiler@22.2.1': dependencies: tslib: 2.8.1 - '@angular/core@22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2)': - dependencies: - rxjs: 7.8.2 - tslib: 2.8.1 - optionalDependencies: - '@angular/compiler': 22.0.4 - '@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)': dependencies: rxjs: 7.8.2 @@ -5506,26 +5283,6 @@ snapshots: obug: 2.2.1 verkit: 0.3.2 - '@babel/core@8.0.6': - dependencies: - '@babel/code-frame': 8.0.6 - '@babel/generator': 8.0.6 - '@babel/helper-compilation-targets': 8.0.6 - '@babel/helpers': 8.0.5 - '@babel/parser': 8.0.6 - '@babel/template': 8.0.0 - '@babel/traverse': 8.0.6 - '@babel/types': 8.0.6 - '@types/gensync': 1.0.5 - convert-source-map: 2.0.0 - empathic: 2.1.0 - gensync: 1.0.0-beta.2 - import-meta-resolve: 4.2.0 - json5: 2.2.3 - obug: 2.2.1 - verkit: 0.3.2 - optional: true - '@babel/generator@8.0.6': dependencies: '@babel/parser': 8.0.6 @@ -5545,9 +5302,6 @@ snapshots: '@babel/helper-globals@8.0.6': {} - '@babel/helper-string-parser@7.29.7': - optional: true - '@babel/helper-string-parser@8.0.6': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -5561,11 +5315,6 @@ snapshots: '@babel/template': 8.0.0 '@babel/types': 8.0.6 - '@babel/parser@7.29.9': - dependencies: - '@babel/types': 7.29.8 - optional: true - '@babel/parser@8.0.6': dependencies: '@babel/types': 8.0.6 @@ -5586,12 +5335,6 @@ snapshots: '@babel/types': 8.0.6 obug: 2.2.1 - '@babel/types@7.29.8': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - optional: true - '@babel/types@8.0.6': dependencies: '@babel/helper-string-parser': 8.0.6 @@ -6038,9 +5781,6 @@ snapshots: optionalDependencies: '@types/node': 20.19.43 - '@istanbuljs/schema@0.1.6': - optional: true - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -6518,81 +6258,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.62.2': - optional: true - - '@rollup/rollup-android-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-x64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.2': - optional: true - '@schematics/angular@22.2.1(chokidar@5.0.0)': dependencies: '@angular-devkit/core': 22.2.1(chokidar@5.0.0) @@ -6896,9 +6561,9 @@ snapshots: '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1))': + '@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1))': dependencies: - vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1) + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) '@vitest/expect@4.1.11': dependencies: @@ -6909,13 +6574,13 @@ snapshots: chai: 6.3.0 tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1))': + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1) + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -7030,8 +6695,8 @@ snapshots: agent-base@9.0.0: {} - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: + ajv-formats@3.0.1: + dependencies: ajv: 8.20.0 ajv@6.15.0: @@ -8126,18 +7791,6 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 - istanbul-lib-coverage@3.2.2: - optional: true - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 8.0.6 - '@babel/parser': 7.29.9 - '@istanbuljs/schema': 0.1.6 - istanbul-lib-coverage: 3.2.2 - semver: 7.8.5 - optional: true - jiti@2.7.0: {} js-tokens@10.0.0: {} @@ -8165,7 +7818,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.2 - undici: 8.11.2 + undici: 7.30.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -9026,38 +8679,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.8 '@rolldown/binding-win32-x64-msvc': 1.2.8 - rollup@4.62.2: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.2 - '@rollup/rollup-android-arm64': 4.62.2 - '@rollup/rollup-darwin-arm64': 4.62.2 - '@rollup/rollup-darwin-x64': 4.62.2 - '@rollup/rollup-freebsd-arm64': 4.62.2 - '@rollup/rollup-freebsd-x64': 4.62.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 - '@rollup/rollup-linux-arm-musleabihf': 4.62.2 - '@rollup/rollup-linux-arm64-gnu': 4.62.2 - '@rollup/rollup-linux-arm64-musl': 4.62.2 - '@rollup/rollup-linux-loong64-gnu': 4.62.2 - '@rollup/rollup-linux-loong64-musl': 4.62.2 - '@rollup/rollup-linux-ppc64-gnu': 4.62.2 - '@rollup/rollup-linux-ppc64-musl': 4.62.2 - '@rollup/rollup-linux-riscv64-gnu': 4.62.2 - '@rollup/rollup-linux-riscv64-musl': 4.62.2 - '@rollup/rollup-linux-s390x-gnu': 4.62.2 - '@rollup/rollup-linux-x64-gnu': 4.62.2 - '@rollup/rollup-linux-x64-musl': 4.62.2 - '@rollup/rollup-openbsd-x64': 4.62.2 - '@rollup/rollup-openharmony-arm64': 4.62.2 - '@rollup/rollup-win32-arm64-msvc': 4.62.2 - '@rollup/rollup-win32-ia32-msvc': 4.62.2 - '@rollup/rollup-win32-x64-gnu': 4.62.2 - '@rollup/rollup-win32-x64-msvc': 4.62.2 - fsevents: 2.3.3 - optional: true - router@2.2.0: dependencies: debug: 4.4.3 @@ -9571,6 +9192,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.30.0: {} + undici@8.11.2: {} unicorn-magic@0.1.0: {} @@ -9639,10 +9262,26 @@ snapshots: sass: 1.104.1 sass-embedded: 1.104.1 - vitest@4.1.11(@types/node@20.19.43)(jsdom@28.1.0)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)): + vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + less: 4.9.1 + sass: 1.105.1 + sass-embedded: 1.105.1 + + vitest@4.1.11(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@28.1.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -9659,13 +9298,24 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1) + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 jsdom: 28.1.0 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml w3c-xmlserializer@5.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 375473c..b22948e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,17 @@ packages: - projects/* +# Do not auto-install the library's peer dependencies. The root already owns a +# single @angular/* copy; auto-installing peers created a second, divergent +# @angular/core under projects/ngx-theme-stack/node_modules and gave the +# Angular unit-test runner two TestBed instances (every spec then failed with +# "Need to call TestBed.initTestEnvironment() first"). +autoInstallPeers: false + +# Resolve peer dependencies from the workspace root so the library and the +# apps share a single copy of @angular/*. +resolvePeersFromWorkspaceRoot: true + onlyBuiltDependencies: - '@parcel/watcher' - esbuild @@ -17,6 +28,10 @@ overrides: undici@<6.27.0: '>=6.27.0' undici@>=7.0.0 <7.28.0: '>=7.28.0' undici@>=8.0.0 <8.5.0: '>=8.5.0' + # jsdom@28 requires undici v7's subpath layout (lib/handler/wrap-handler.js), + # which undici v8 removed. Scope the override to jsdom so release-it keeps + # the undici 8.x it wants. + jsdom@28.1.0>undici: '^7.21.0' vite@<=7.3.4: '>=7.3.5' esbuild@<0.28.1: '>=0.28.1' '@babel/core@<=7.29.0': '>=7.29.6' From 585e945374faefb68aa11962c8e4bb1596304452 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 10:17:32 -0500 Subject: [PATCH 04/15] fix(lib): return the merged theme list from provideThemeStack provideThemeStack() returned the raw user `themes` array instead of the merged, deduplicated list (built-ins + custom), so NgConfig.themes lost the built-in themes. The internal resolvedThemes array already carried the correct data, but the public field regressed. Also: - CoreThemeService falls back to deriving resolvedThemes from themes when a config is injected without it, so a hand-rolled NGX_THEME_STACK_CONFIG provider cannot silently empty the valid-theme set. - Use T[] instead of Array in the two places the new types introduced (eslint @typescript-eslint/array-type). Verified: pnpm test 110 passing. --- projects/ngx-theme-stack/src/lib/config/index.ts | 8 ++++---- .../src/lib/core/core-theme.service.spec.ts | 13 +++++++++---- .../src/lib/core/core-theme.service.ts | 13 ++++++++++--- projects/ngx-theme-stack/src/lib/types.ts | 2 +- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/projects/ngx-theme-stack/src/lib/config/index.ts b/projects/ngx-theme-stack/src/lib/config/index.ts index 3d64ff2..5b97d78 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.ts @@ -28,7 +28,7 @@ export const DEFAULT_NG_CONFIG: NgConfig = { storageKey: 'ngx-theme-stack', mode: 'class', strategy: 'critters', - themes: [...DEFAULT_THEMES] as Array, + themes: [...DEFAULT_THEMES] as (NgTheme | NgThemeOption)[], resolvedThemes: normalizeThemeInputs(DEFAULT_THEMES as unknown as NgThemeInput[]), }; @@ -144,9 +144,9 @@ export function provideThemeStack( useValue: { ...DEFAULT_NG_CONFIG, ...config, - themes: config.themes - ? (config.themes as unknown as NgTheme[]) - : (DEFAULT_NG_CONFIG.themes as unknown as NgTheme[]), + // `themes` stays the merged, deduplicated name list (backwards compatible); + // `resolvedThemes` carries the normalized per-theme scheme hints. + themes: themes as unknown as NgTheme[], resolvedThemes, } as NgConfig, }; diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts index 6f237da..7b676c8 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts @@ -1,7 +1,7 @@ import { Component, PLATFORM_ID, ChangeDetectionStrategy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NGX_THEME_STACK_CONFIG } from '../config'; -import { NgConfig, normalizeThemeInputs } from '../types'; +import { NgConfig, NgThemeInput, normalizeThemeInputs } from '../types'; import { CoreThemeService } from './core-theme.service'; @@ -53,15 +53,20 @@ function setup( vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(matchMediaMock)); + const effectiveThemes = (config.themes as NgThemeInput[] | undefined) ?? [ + 'light', + 'dark', + 'system', + ]; const fullConfig: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', - themes: ['light', 'dark', 'system'], strategy: 'critters', - resolvedThemes: normalizeThemeInputs(['light', 'dark', 'system']), + themes: effectiveThemes, + resolvedThemes: normalizeThemeInputs(effectiveThemes), ...config, - }; + } as NgConfig; TestBed.configureTestingModule({ providers: [ diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts index aad6752..da073a4 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { NgxThemeStackError } from '../errors'; -import { NgSystemTheme, NgTheme, ResolvedTheme } from '../types'; +import { NgSystemTheme, NgTheme, NgThemeInput, ResolvedTheme, normalizeThemeInputs } from '../types'; /** * Core service for managing the application's color theme. @@ -37,8 +37,15 @@ export class CoreThemeService { /** List of available themes for Select/Cycle services. Defaults to ['system', 'light', 'dark']. */ readonly availableThemes = this.#config.themes; - /** Normalized themes with per-theme color-scheme hints. */ - readonly resolvedThemes: ResolvedTheme[] = this.#config.resolvedThemes; + /** + * Normalized themes with per-theme color-scheme hints. + * + * Falls back to deriving the list from `themes` when a config is injected + * without `resolvedThemes` (e.g. a hand-rolled `NGX_THEME_STACK_CONFIG` + * provider in tests), so the service never loses its valid-theme set. + */ + readonly resolvedThemes: ResolvedTheme[] = + this.#config.resolvedThemes ?? normalizeThemeInputs(this.#config.themes as NgThemeInput[]); /** Plain string array of theme names, suitable for template binding. */ readonly themeNames: string[] = this.resolvedThemes.map((t) => t.name); diff --git a/projects/ngx-theme-stack/src/lib/types.ts b/projects/ngx-theme-stack/src/lib/types.ts index 652845b..be00d53 100644 --- a/projects/ngx-theme-stack/src/lib/types.ts +++ b/projects/ngx-theme-stack/src/lib/types.ts @@ -205,7 +205,7 @@ export interface NgConfig { * // { name: 'dark', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }, * // { name: 'ocean', scheme: 'dark' }] */ - themes: Array | NgThemeOption>; + themes: (NgTheme | NgThemeOption)[]; /** * Internal, normalized view of {@link NgConfig.themes}. From f349eabfebef5fe52caa12d5774c52f690d7729d Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 10:17:43 -0500 Subject: [PATCH 05/15] fix(schematics): keep object-form themes intact in sync The sync schematic extracted `themes: [...]` and split the array body on commas, which shredded object-form entries ({ name: 'sepia', scheme: 'light' }) into broken fragments and rewrote app.config.ts to invalid TypeScript on the next build. Extracted the parsing into schematics/utils/theme-input.ts, which splits on top-level commas only (quote- and bracket-aware) and parses both 'name' and { name, scheme } entries with order-independent keys. Verified: pnpm run build:demo now succeeds and a second sync is idempotent; 14 new specs cover the parser. --- .../src/app/app.config.ts | 8 +- .../ngx-theme-stack/schematics/sync/index.ts | 34 +++--- .../schematics/utils/theme-input.ts | 93 ++++++++++++++++ .../src/schematics/theme-input.spec.ts | 101 ++++++++++++++++++ 4 files changed, 207 insertions(+), 29 deletions(-) create mode 100644 projects/ngx-theme-stack/schematics/utils/theme-input.ts create mode 100644 projects/ngx-theme-stack/src/schematics/theme-input.spec.ts diff --git a/projects/demo-ngx-theme-stack/src/app/app.config.ts b/projects/demo-ngx-theme-stack/src/app/app.config.ts index ed9a2d1..d66828d 100644 --- a/projects/demo-ngx-theme-stack/src/app/app.config.ts +++ b/projects/demo-ngx-theme-stack/src/app/app.config.ts @@ -16,13 +16,7 @@ export const appConfig: ApplicationConfig = { ), provideClientHydration(withEventReplay()), provideThemeStack({ - themes: [ - 'system', - 'light', - 'dark', - { name: 'sunset', scheme: 'dark' }, - { name: 'sepia', scheme: 'light' }, - ] as const, + themes: ['system', 'light', 'dark', { name: 'sunset', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }] as const, defaultTheme: 'sunset', storageKey: 'ngx-theme-stack', mode: 'class', diff --git a/projects/ngx-theme-stack/schematics/sync/index.ts b/projects/ngx-theme-stack/schematics/sync/index.ts index 4f13e9e..cf3d083 100644 --- a/projects/ngx-theme-stack/schematics/sync/index.ts +++ b/projects/ngx-theme-stack/schematics/sync/index.ts @@ -4,6 +4,7 @@ import { assertAngularProject, buildProvideCall } from '../ng-add/utils'; import { DEFAULTS } from '../ng-add/constants'; import { Schema } from './schema'; import { buildAntiFlashScript } from '../utils/anti-flash-script'; +import { parseThemeInputArray } from '../utils/theme-input'; // ── Regex patterns ──────────────────────────────────────────────────────────── @@ -101,28 +102,17 @@ function extractConfig( const strategy = OPTION_STRATEGY_RE.exec(opts)?.[1] ?? undefined; const themesRaw = OPTION_THEMES_RE.exec(opts)?.[1] ?? ''; - const themes: string[] = themesRaw - ? themesRaw - .split(',') - .map((t) => t.trim().replace(/^['"]|['"]$/g, '')) - .filter(Boolean) - : [...DEFAULTS.themes]; - - // Detect object-form theme entries with scheme hints. - // Matches patterns like: { name: 'sepia', scheme: 'light' } - const schemeMap: Record = {}; - const objThemeRe = /\{\s*name\s*:\s*['"]([^'"]+)['"]\s*,?\s*scheme\s*:\s*['"]([^'"]+)['"]\s*\}/g; - let m: RegExpExecArray | null; - while ((m = objThemeRe.exec(themesRaw)) !== null) { - schemeMap[m[1]] = m[2]; - } - - // Ensure themes from object form are included in the themes list. - for (const name of Object.keys(schemeMap)) { - if (!themes.includes(name)) themes.push(name); - } - - return { mode, strategy, storageKey, defaultTheme, themes, schemeMap: Object.keys(schemeMap).length ? schemeMap : undefined }; + const { themes, schemeMap } = parseThemeInputArray(themesRaw); + if (themes.length === 0) themes.push(...DEFAULTS.themes); + + return { + mode, + strategy, + storageKey, + defaultTheme, + themes, + schemeMap: Object.keys(schemeMap).length ? schemeMap : undefined, + }; } // Fallback to defaults if no config file found diff --git a/projects/ngx-theme-stack/schematics/utils/theme-input.ts b/projects/ngx-theme-stack/schematics/utils/theme-input.ts new file mode 100644 index 0000000..3c9f9e3 --- /dev/null +++ b/projects/ngx-theme-stack/schematics/utils/theme-input.ts @@ -0,0 +1,93 @@ +/** + * Parsing helpers for the `themes` option of `provideThemeStack()`. + * + * Themes can be written in two shapes, mixable in one array: + * themes: ['system', 'light', { name: 'sepia', scheme: 'light' }] as const + * + * A naive `split(',')` breaks the object entries, so the array body is split + * on top-level commas only (ignoring commas nested in braces, brackets, + * parentheses, or quotes). + */ + +/** One parsed theme entry: its name and optional color-scheme hint. */ +export interface ParsedThemeInput { + name: string; + scheme?: string; +} + +/** + * Splits the body of a `themes: [...]` array into top-level entries. + * + * Commas inside `{}`, `[]`, `()`, or string literals do not split. + */ +export function splitTopLevelEntries(body: string): string[] { + const entries: string[] = []; + let depth = 0; + let current = ''; + let quote: string | null = null; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + + if (quote) { + current += ch; + if (ch === quote && body[i - 1] !== '\\') quote = null; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + + if (ch === '{' || ch === '[' || ch === '(') depth++; + else if (ch === '}' || ch === ']' || ch === ')') depth--; + + if (ch === ',' && depth === 0) { + entries.push(current); + current = ''; + continue; + } + + current += ch; + } + + if (current.trim()) entries.push(current); + return entries.map((e) => e.trim()).filter(Boolean); +} + +/** + * Parses one theme entry, either `'name'` or `{ name: 'n', scheme: 's' }`. + * Property order inside the object is not significant. + */ +export function parseThemeEntry(entry: string): ParsedThemeInput | null { + if (entry.startsWith('{')) { + const name = /name\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; + const scheme = /scheme\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; + return name ? { name, scheme } : null; + } + const match = /^['"]([^'"]+)['"]$/.exec(entry); + return match ? { name: match[1] } : null; +} + +/** + * Parses the raw body of a `themes: [...]` array into ordered theme names and + * a `name -> scheme` map for the entries that declare a hint. + */ +export function parseThemeInputArray(body: string): { + themes: string[]; + schemeMap: Record; +} { + const themes: string[] = []; + const schemeMap: Record = {}; + + for (const entry of splitTopLevelEntries(body)) { + const parsed = parseThemeEntry(entry); + if (!parsed) continue; + themes.push(parsed.name); + if (parsed.scheme) schemeMap[parsed.name] = parsed.scheme; + } + + return { themes, schemeMap }; +} diff --git a/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts new file mode 100644 index 0000000..698485b --- /dev/null +++ b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts @@ -0,0 +1,101 @@ +import { + parseThemeEntry, + parseThemeInputArray, + splitTopLevelEntries, +} from '../../schematics/utils/theme-input'; + +describe('splitTopLevelEntries', () => { + it('splits plain string entries', () => { + expect(splitTopLevelEntries("'light', 'dark', 'system'")).toEqual([ + "'light'", + "'dark'", + "'system'", + ]); + }); + + it('keeps object entries intact', () => { + expect( + splitTopLevelEntries("'system', { name: 'sepia', scheme: 'light' }, 'ocean'"), + ).toEqual(["'system'", "{ name: 'sepia', scheme: 'light' }", "'ocean'"]); + }); + + it('does not split on commas inside quotes', () => { + expect(splitTopLevelEntries("'a,b', 'c'")).toEqual(["'a,b'", "'c'"]); + }); + + it('does not split on commas inside nested brackets', () => { + expect(splitTopLevelEntries("['a', 'b'], 'c'")).toEqual(["['a', 'b']", "'c'"]); + }); + + it('handles a trailing comma gracefully', () => { + expect(splitTopLevelEntries("'a', 'b',")).toEqual(["'a'", "'b'"]); + }); + + it('returns an empty list for an empty body', () => { + expect(splitTopLevelEntries('')).toEqual([]); + }); +}); + +describe('parseThemeEntry', () => { + it('parses a plain quoted name', () => { + expect(parseThemeEntry("'sepia'")).toEqual({ name: 'sepia' }); + }); + + it('parses an object with name and scheme', () => { + expect(parseThemeEntry("{ name: 'sepia', scheme: 'light' }")).toEqual({ + name: 'sepia', + scheme: 'light', + }); + }); + + it('parses an object with scheme before name', () => { + expect(parseThemeEntry("{ scheme: 'dark', name: 'ocean' }")).toEqual({ + name: 'ocean', + scheme: 'dark', + }); + }); + + it('parses an object without a scheme', () => { + expect(parseThemeEntry('{ name: "forest" }')).toEqual({ name: 'forest' }); + }); + + it('returns null for an unrecognized entry', () => { + expect(parseThemeEntry('{ scheme: "dark" }')).toBeNull(); + expect(parseThemeEntry('garbage')).toBeNull(); + }); +}); + +describe('parseThemeInputArray', () => { + it('parses the mixed string + object form used by the demo', () => { + const body = + "'system', 'light', 'dark', { name: 'sunset', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['system', 'light', 'dark', 'sunset', 'sepia'], + schemeMap: { sunset: 'dark', sepia: 'light' }, + }); + }); + + it('parses a string-only array with an empty schemeMap', () => { + expect(parseThemeInputArray("'system', 'light', 'dark'")).toEqual({ + themes: ['system', 'light', 'dark'], + schemeMap: {}, + }); + }); + + it('multiline object entries survive a comma split', () => { + const body = ` + 'light', + 'dark', + { + name: 'sepia', + scheme: 'light' + }, + `; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'dark', 'sepia'], + schemeMap: { sepia: 'light' }, + }); + }); +}); From 7e1655e2aa023a88276e2308d5a0ba6b9289d55e Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 10:41:56 -0500 Subject: [PATCH 06/15] docs(odd): record the test-runner repair and verification evidence --- odd/tasks/theme-color-scheme.md | 56 ++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/odd/tasks/theme-color-scheme.md b/odd/tasks/theme-color-scheme.md index 5174fed..4a11320 100644 --- a/odd/tasks/theme-color-scheme.md +++ b/odd/tasks/theme-color-scheme.md @@ -57,12 +57,60 @@ themes: [ - [x] T6: Sync schematic detects + preserves object form - [x] T7: Specs (types, config, core, services, anti-flash) - [x] T8: Demo app updated (sepia theme + index.html + themes.css) -- [ ] T9: Work-unit commit + verify build +- [x] T9: Work-unit commits + verify build +- [x] T10: Repair the unit-test runner (jsdom/undici + duplicate Angular) +- [x] T11: Fix sync theme parsing for object form + +## Commits (branch `dev`) + +| Commit | Subject | +| --- | --- | +| `5c631ae` | feat(lib): per-theme color-scheme hints for custom themes | +| `15622ea` | docs(lib): document per-theme color-scheme hints | +| `5622c07` | fix(deps): pin jsdom's undici and drop the duplicate Angular copy | +| `585e945` | fix(lib): return the merged theme list from provideThemeStack | +| `f349eab` | fix(schematics): keep object-form themes intact in sync | + +## Verification + +- `pnpm test`: 110 passing in the library + 1 in the demo (was: worker crash, + zero tests executed). +- `pnpm run build:lib`: succeeds; dist exports `NgColorScheme`, + `ResolvedTheme` and `normalizeThemeInputs`, and the schematics carry + `schemeMap`. +- `pnpm run build:demo`: succeeds; the `prebuild` sync runs end to end and the + generated anti-flash script contains `s={"sunset":"dark","sepia":"light"}`. +- `pnpm exec ng lint `: clean for both projects. +- Sync is idempotent: a second run produces byte-identical files. +- `pnpm install --frozen-lockfile`: OK (CI parity). + +## Test runner incident (T10) — root cause + +Two independent defects had made the Angular unit-test runner unusable: + +1. **jsdom/undici mismatch.** `jsdom@28.1.0` requires `undici@^7.21.0` and + imports `undici/lib/handler/wrap-handler.js`, a subpath removed in undici 8. + The workspace hoisted `undici@8.11.2` (via `release-it`), so the vitest + worker died at startup with `MODULE_NOT_FOUND` and no test ever ran. + Fixed with a jsdom-scoped override: `jsdom@28.1.0>undici: '^7.21.0'`. + +2. **Duplicate Angular copy.** The library declares `@angular/core` and + `@angular/common` as `peerDependencies`, so pnpm auto-installed its own copy + (`@angular/core@22.0.4`) under `projects/ngx-theme-stack/node_modules` while + the workspace root had `22.2.1`. The builder initializes `TestBed` from the + root copy, so every spec failed with "Need to call + TestBed.initTestEnvironment() first". Fixed with `autoInstallPeers: false` + (camelCase — pnpm silently ignores the snake_case key) so the library + resolves Angular from the workspace root. ## Notes -- Pre-existing test infra incident: `pnpm test` fails on this branch and on - main with a jsdom `MODULE_NOT_FOUND` (undici) error in the vitest worker. - This is not caused by this feature; it exists on main too. - `tsc --noEmit` passes cleanly for `tsconfig.lib.json`, `tsconfig.schematics.json`, and `tsconfig.spec.json`. +- Pre-existing and out of scope: `pnpm run lint` (all projects) also lints the + generated `dist/**/*.d.ts`, reporting 8 unrelated errors. Per-project lint is + clean. Also pre-existing: 3 `@angular-eslint/component-selector` errors in the + demo components (`theme-cycle.ts`, `theme-select.ts`, `theme-toggle.ts`), + identical on `main`. +- Brand-new finding worth a follow-up: `.gitignore` does not exclude `dist` + from lint, and the demo components use a non-project selector prefix. From a4d741266c8641b13dad225b4683b58ecaed24da Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 10:56:59 -0500 Subject: [PATCH 07/15] docs(skill): teach the generated SKILL.md about color-scheme hints The skill generator still described themes as string-only, so an agent following it would add a custom theme without a scheme hint and leave native widgets (scrollbars, form controls) on the OS colour scheme. Adds the object form and the scheme table to the API reference, documents themeNames/resolvedThemes, and turns the custom-theme question into a decision the agent must ask (light or dark native widgets) before generating config. --- .agents/skills/ngx-theme-stack/SKILL.md | 3 +- .../references/api-reference.md | 45 +++++++++++++++++ .../ngx-theme-stack/schematics/skill/index.ts | 48 ++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/.agents/skills/ngx-theme-stack/SKILL.md b/.agents/skills/ngx-theme-stack/SKILL.md index 289621d..c561c0c 100644 --- a/.agents/skills/ngx-theme-stack/SKILL.md +++ b/.agents/skills/ngx-theme-stack/SKILL.md @@ -18,12 +18,13 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (`ThemeCycleService`) - Rotate through all themes. - **Select** (`ThemeSelectService`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as light or dark, then declare it with the object form in `themes`: `{ name: 'sepia', scheme: 'light' }`. Without a `scheme`, a custom theme leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call `provideThemeStack()` once in root `app.config.ts`. Custom themes merge with defaults. +- `themes` accepts plain names and `{ name, scheme }` objects, mixable in one array. `scheme` is `'light' | 'dark' | 'auto' | 'none'` and only controls the CSS `color-scheme` hint for native widgets; it does not select a theme. Built-in names keep their implicit hint. - **Theme Synchronization**: Syncs theme configuration in `app.config.ts` with `index.html` assets. - **Manual execution**: Run `pnpm run ngx-theme-stack:sync` (or `npm run ngx-theme-stack:sync` / `yarn run ngx-theme-stack:sync`). - **Auto-Sync**: Runs automatically before serving or building via `"prestart"` and `"prebuild"` hooks in `package.json`. diff --git a/.agents/skills/ngx-theme-stack/references/api-reference.md b/.agents/skills/ngx-theme-stack/references/api-reference.md index 8778da1..fd6627f 100644 --- a/.agents/skills/ngx-theme-stack/references/api-reference.md +++ b/.agents/skills/ngx-theme-stack/references/api-reference.md @@ -14,10 +14,53 @@ provideThemeStack({ }) ``` +### Themes: string form and object form + +Each entry in `themes` is either a plain name or an object that adds a +`color-scheme` hint. Both shapes can be mixed in one array. The hint only +affects native browser widgets (scrollbars, inputs, form controls); it does +not pick a theme for you. + +```typescript +provideThemeStack({ + themes: [ + 'system', + 'light', + 'dark', + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ] as const, +}) +``` + +| `scheme` | Effect on `color-scheme` | +| --- | --- | +| `'light'` | Sets `color-scheme: light` while the theme is active. | +| `'dark'` | Sets `color-scheme: dark` while the theme is active. | +| `'auto'` (default when omitted) | No hint; the browser decides. | +| `'none'` | Explicitly removes the hint. | + +Built-in names keep their implicit hint (`light` → light, `dark` → dark, +`system` → auto). Prefer the object form for any custom theme whose native +widgets must match the theme, otherwise scrollbars keep the OS colour. + +```typescript +// Wrong: sepia's scrollbars stay OS-coloured +provideThemeStack({ themes: ['sepia'] as const }) + +// Right: declares the intended native colour scheme +provideThemeStack({ themes: [{ name: 'sepia', scheme: 'light' }] as const }) +``` + **Throws `NgxThemeStackError` when:** - A theme entry is empty, or `defaultTheme` is not in themes, or `storageKey` is empty. - `setTheme()` is called with a theme not in the configured themes list. +> After changing `themes` (adding, removing, renaming, or adding a `scheme`), +> run the sync schematic so the anti-flash script in `index.html` picks up the +> new names and hints. Otherwise a custom theme can silently fall back to the +> default on first paint. + --- ## CoreThemeService @@ -33,6 +76,8 @@ Foundation service managing state (signals), persistence, system preference, and | `isDark()` / `isLight()` | `Signal` | `true` for dark/light (returns `false` for custom themes). | | `isSystem()` / `isHydrated()` | `Signal` | System choice active / SSR hydration finished. | | `availableThemes` | `string[]` | All configured themes including built-ins. | +| `themeNames` | `string[]` | Same list, plain names only — bind directly in templates. | +| `resolvedThemes` | `{ name, scheme }[]` | Normalized themes with their color-scheme hint. | | `setTheme(theme)` | `(theme: string) => void` | Validates, persists, and applies the theme to DOM. | --- diff --git a/projects/ngx-theme-stack/schematics/skill/index.ts b/projects/ngx-theme-stack/schematics/skill/index.ts index 7329971..2ac0ff5 100644 --- a/projects/ngx-theme-stack/schematics/skill/index.ts +++ b/projects/ngx-theme-stack/schematics/skill/index.ts @@ -25,12 +25,13 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (\`ThemeCycleService\`) - Rotate through all themes. - **Select** (\`ThemeSelectService\`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as light or dark, then declare it with the object form in \`themes\`: \`{ name: 'sepia', scheme: 'light' }\`. Without a \`scheme\`, a custom theme leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call \`provideThemeStack()\` once in root \`app.config.ts\`. Custom themes merge with defaults. +- \`themes\` accepts plain names and \`{ name, scheme }\` objects, mixable in one array. \`scheme\` is \`'light' | 'dark' | 'auto' | 'none'\` and only controls the CSS \`color-scheme\` hint for native widgets; it does not select a theme. Built-in names keep their implicit hint. - **Theme Synchronization**: Syncs theme configuration in \`app.config.ts\` with \`index.html\` assets. - **Manual execution**: Run \`pnpm run ngx-theme-stack:sync\` (or \`npm run ngx-theme-stack:sync\` / \`yarn run ngx-theme-stack:sync\`). - **Auto-Sync**: Runs automatically before serving or building via \`"prestart"\` and \`"prebuild"\` hooks in \`package.json\`. @@ -188,10 +189,53 @@ provideThemeStack({ }) \`\`\` +### Themes: string form and object form + +Each entry in \`themes\` is either a plain name or an object that adds a +\`color-scheme\` hint. Both shapes can be mixed in one array. The hint only +affects native browser widgets (scrollbars, inputs, form controls); it does +not pick a theme for you. + +\`\`\`typescript +provideThemeStack({ + themes: [ + 'system', + 'light', + 'dark', + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ] as const, +}) +\`\`\` + +| \`scheme\` | Effect on \`color-scheme\` | +| --- | --- | +| \`'light'\` | Sets \`color-scheme: light\` while the theme is active. | +| \`'dark'\` | Sets \`color-scheme: dark\` while the theme is active. | +| \`'auto'\` (default when omitted) | No hint; the browser decides. | +| \`'none'\` | Explicitly removes the hint. | + +Built-in names keep their implicit hint (\`light\` → light, \`dark\` → dark, +\`system\` → auto). Prefer the object form for any custom theme whose native +widgets must match the theme, otherwise scrollbars keep the OS colour. + +\`\`\`typescript +// Wrong: sepia's scrollbars stay OS-coloured +provideThemeStack({ themes: ['sepia'] as const }) + +// Right: declares the intended native colour scheme +provideThemeStack({ themes: [{ name: 'sepia', scheme: 'light' }] as const }) +\`\`\` + **Throws \`NgxThemeStackError\` when:** - A theme entry is empty, or \`defaultTheme\` is not in themes, or \`storageKey\` is empty. - \`setTheme()\` is called with a theme not in the configured themes list. +> After changing \`themes\` (adding, removing, renaming, or adding a \`scheme\`), +> run the sync schematic so the anti-flash script in \`index.html\` picks up the +> new names and hints. Otherwise a custom theme can silently fall back to the +> default on first paint. + --- ## CoreThemeService @@ -207,6 +251,8 @@ Foundation service managing state (signals), persistence, system preference, and | \`isDark()\` / \`isLight()\` | \`Signal\` | \`true\` for dark/light (returns \`false\` for custom themes). | | \`isSystem()\` / \`isHydrated()\` | \`Signal\` | System choice active / SSR hydration finished. | | \`availableThemes\` | \`string[]\` | All configured themes including built-ins. | +| \`themeNames\` | \`string[]\` | Same list, plain names only — bind directly in templates. | +| \`resolvedThemes\` | \`{ name, scheme }[]\` | Normalized themes with their color-scheme hint. | | \`setTheme(theme)\` | \`(theme: string) => void\` | Validates, persists, and applies the theme to DOM. | --- From 224b543c77183f57cbe99e7cbfa0c0e8f43f3dd9 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 12:40:51 -0500 Subject: [PATCH 08/15] chore(lint): ignore generated output in the flat config The root config matches 'files: ["**/*.ts"]' and ESLint flat config does not ignore dist or build caches by default, so any glob that reaches them reports errors on code nobody wrote: - dist/**.d.ts -> 8 errors (Angular emits #private fields and any) - .agents/** -> 3 component-selector errors (generated skill example components use an app- selector, correct for a consumer app, but the workspace rule demands the lib prefix) Per-project lint already scopes to projects/** so the normal command passes; this closes the latent trap for IDEs, editor plugins, and root-level globs. Verified: eslint run directly on those paths now reports 0 errors, real source is still linted (an injected component-selector error is still caught), pnpm run lint and both per-project lints pass, tests 110+1, build:lib OK. --- eslint.config.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index ba5ff55..021c599 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,26 @@ const tseslint = require("typescript-eslint"); const angular = require("angular-eslint"); module.exports = defineConfig([ + { + // Generated output only. `files: ['**/*.ts']` below matches the whole + // workspace, and ESLint 9+/flat config does not ignore `dist` or build + // caches by default, so any glob that reaches them reports errors on code + // nobody wrote: + // - dist/**.d.ts: Angular emits `#private` fields and `any`. + // - .agents/**: generated skill assets, whose example components use an + // `app-` selector (correct for a consumer app, but the workspace rule + // below demands the `lib` prefix). + // Scoped per-project lint already skips them; this keeps IDE/plugin/root + // globs honest too. + ignores: [ + 'dist/**', + '.agents/**', + '.angular/**', + 'out-tsc/**', + 'coverage/**', + 'vitest-coverage/**', + ], + }, { files: ["**/*.ts"], extends: [ From 16fb4c45c776d0a9c0d081287b47cff32d1444be Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 15:17:25 -0500 Subject: [PATCH 09/15] fix(ci): stop unstable code from reaching the latest dist-tag publish.yml chose the dist-tag from a single heuristic: whether the ref name contained a '-'. Two holes let unstable code become 'latest', which is what npm install ngx-theme-stack resolves for everyone: - a stable tag created outside main (e.g. v4.0.0 on a feature branch) has no '-', so it published to latest; - workflow_dispatch runs on any branch, and ref_name ('dev') has no '-', so the manual path published to latest from wherever it was dispatched. Adds two guards before install/build/publish: - a stable tag must be an ancestor of origin/main; - a manual dispatch must run from main. Prereleases are untouched: a tag containing '-' skips both guards and still publishes under the 'next' dist-tag. Also sets fetch-depth: 0 on checkout. The ancestor check needs origin/main, and the default fetch-depth: 1 leaves no remote-tracking refs, which would have made the guard fail closed and reject every valid release. Verified with scripts/verify-publish-guards.mjs, which extracts the guard run bodies from the workflow itself and executes them against real SHAs: stable tag on main passes, stable tag off main fails, prerelease skips both guards, manual dispatch from dev fails, manual dispatch from main passes. --- .github/workflows/publish.yml | 34 +++++++ odd/tasks/release-hardening.md | 78 ++++++++++++++++ scripts/verify-publish-guards.mjs | 150 ++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 odd/tasks/release-hardening.md create mode 100755 scripts/verify-publish-guards.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f186e69..2b9491b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,40 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + # Full history is required by the guard below: it asks whether a stable + # tag is an ancestor of origin/main. The default `fetch-depth: 1` + # leaves no remote-tracking refs, so the check would fail closed and + # reject every valid release. + fetch-depth: 0 + + # ── Release guards ────────────────────────────────────────────────────── + # `latest` is what `npm install ` resolves for everyone, so nothing + # unstable may reach it. Prereleases (tag name containing '-') skip both + # guards on purpose: they are published under the `next` dist-tag. + - name: Guard — a stable tag must live on main + if: ${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} + run: | + set -euo pipefail + if ! git rev-parse --verify --quiet origin/main >/dev/null; then + echo "::error::origin/main is unavailable, so the tag cannot be verified against main." + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Stable tag '$GITHUB_REF_NAME' is not an ancestor of main. Refusing to publish it as 'latest'." + exit 1 + fi + echo "OK: '$GITHUB_REF_NAME' is on main and may be published as 'latest'." + + - name: Guard — a manual publish must run from main + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + if [ "$GITHUB_REF_NAME" != "main" ]; then + echo "::error::Manual publish is only allowed from 'main' (got '$GITHUB_REF_NAME')." + exit 1 + fi + echo "OK: manual publish from main." - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/odd/tasks/release-hardening.md b/odd/tasks/release-hardening.md new file mode 100644 index 0000000..920d33b --- /dev/null +++ b/odd/tasks/release-hardening.md @@ -0,0 +1,78 @@ +# Release Hardening — `latest` only from `main` + +## Context + +`publish.yml` published to the `latest` dist-tag based on a single heuristic: +whether the ref name contained a `-`. + +That heuristic is correct for prereleases, but it had two holes: + +1. **A stable tag created outside `main` still published to `latest`.** + Tag `v4.0.0` on a feature branch → no `-` → `npm publish` → `latest`. +2. **`workflow_dispatch` published to `latest` from any branch.** + `github.ref_name` for a manual run is the branch (`dev`), which has no `-`, + so the `else` branch ran and published whatever version was in the manifest. + +`latest` is what `npm install ngx-theme-stack` resolves for everyone, so +unstable code must never reach it. + +## Scope (option A — approved) + +`dev` keeps its name and remains the channel for `next` prereleases. + +Two guards added to `publish.yml`, before the install/build/publish steps: + +| Guard | Applies when | Rejects when | +| --- | --- | --- | +| A stable tag must live on `main` | `ref_type == 'tag'` and the tag has no `-` | the tagged commit is not an ancestor of `origin/main` | +| A manual publish must run from `main` | `event_name == 'workflow_dispatch'` | `ref_name != 'main'` | + +Plus `fetch-depth: 0` on checkout: the first guard asks +`git merge-base --is-ancestor "$GITHUB_SHA" origin/main`, and the default +`fetch-depth: 1` leaves no remote-tracking refs, so the check would fail closed +and reject **every** valid release. + +Prereleases keep working untouched: a tag containing `-` skips both guards and +is published under the `next` dist-tag. + +## Verification + +`scripts/verify-publish-guards.mjs` extracts the guard `run:` bodies **from the +workflow file** (not a copy) and executes them with real SHAs from this repo. +It also asserts the mirrored `if:` evaluators are byte-identical to the ones in +the workflow, so the mirror cannot drift silently. + +```bash +node scripts/verify-publish-guards.mjs +``` + +| Scenario | Expected | Result | +| --- | --- | --- | +| 1. stable tag ON main | pass | PASS | +| 2. stable tag OFF main | fail | PASS — `::error::Stable tag 'v4.0.0' is not an ancestor of main` | +| 3. prerelease tag on dev | guards skip | PASS | +| 4. manual dispatch from dev | fail | PASS — `::error::Manual publish is only allowed from 'main'` | +| 5. manual dispatch from main | pass | PASS | + +Also verified: the workflow parses as valid YAML and the step order is intact. + +## Decisions taken + +- **Branch name**: `dev` stays. A rename to `next` (for symmetry with the + dist-tag) was offered and declined. +- **No OIDC migration yet**: `NPM_TOKEN` remains. Tracked as follow-up B. +- **No LTS machinery yet**: the project has a single live major (3.x). Tracked + as follow-up C. + +## Follow-ups (not in this unit) + +- **B** — migrate to npm trusted publishing (OIDC), dropping the long-lived + `NPM_TOKEN` and getting provenance automatically. +- **C** — full channel model: `release/N.x` branches + `vN-lts` dist-tags. +- Wire `scripts/verify-publish-guards.mjs` into CI so guard edits are checked. +- Observation, unchanged: `deploy.yml` deploys the public demo only on a push to + `main`, never on a tag. Publishing `next` therefore leaves the demo untouched. +- Observation, unchanged: the GitHub Release step passes the **whole** + `CHANGELOG.md` as release notes for every release, not just the new section. +- Observation, unchanged: every prerelease currently maps to the `next` + dist-tag, so an `rc` tag would also land on `next`. diff --git a/scripts/verify-publish-guards.mjs b/scripts/verify-publish-guards.mjs new file mode 100755 index 0000000..6f7bc29 --- /dev/null +++ b/scripts/verify-publish-guards.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Verifies the release guards in .github/workflows/publish.yml. + * + * Run it after touching the workflow: + * node scripts/verify-publish-guards.mjs + * + * The guard `run:` bodies are extracted FROM THE WORKFLOW FILE and executed with + * real SHAs from this repo, so this exercises the actual shell logic CI runs + * rather than a copy of it. The `if:` conditions are mirrored below, but the + * harness first asserts that the mirrored strings are byte-identical to the ones + * in the workflow, so the mirror cannot silently drift. + * + * Why this exists: `latest` is what `npm install ngx-theme-stack` resolves for + * everyone. A stable tag created outside main must never reach it, and the + * manual `workflow_dispatch` path used to reach it from any branch. + */ +import { readFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const WF = resolve(repoRoot, '.github/workflows/publish.yml'); +const wf = readFileSync(WF, 'utf8'); +const sh = (args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); + +// ── 1. Parse steps (6-space indented "- name:") ─────────────────────────────── +const lines = wf.split('\n'); +const steps = []; +for (let i = 0; i < lines.length; i++) { + const m = /^ {6}- name: (.+)$/.exec(lines[i]); + if (!m) continue; + const body = []; + for (let j = i + 1; j < lines.length; j++) { + if (/^ {6}- name: /.test(lines[j]) || /^ {4,6}[a-z]+:/.test(lines[j])) break; + body.push(lines[j]); + } + steps.push({ name: m[1].trim(), body: body.join('\n') }); +} + +const guards = steps.filter((s) => s.name.startsWith('Guard — ')); +if (guards.length !== 2) throw new Error(`expected 2 guards, found ${guards.length}`); + +/** Pull the shell out of a step's `run: |` block. */ +function runBody(step) { + const idx = step.body.indexOf('run: |'); + if (idx === -1) throw new Error(`no run block in "${step.name}"`); + const after = step.body.slice(idx + 'run: |'.length).split('\n'); + const out = []; + for (const l of after) { + if (l.trim() === '') { out.push(''); continue; } + if (!/^ {10}/.test(l)) break; // dedent ends the block + out.push(l.slice(10)); + } + return out.join('\n'); +} + +function ifExpr(step) { + const m = /^ {8}if: (.+)$/m.exec(step.body); + return m ? m[1].trim() : null; +} + +// ── 2. Assert the mirrored conditions are identical to the workflow ─────────── +const EXPECTED_IF = { + 'Guard — a stable tag must live on main': + "${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}", + 'Guard — a manual publish must run from main': + "${{ github.event_name == 'workflow_dispatch' }}", +}; +for (const g of guards) { + const actual = ifExpr(g); + if (actual !== EXPECTED_IF[g.name]) { + throw new Error( + `if: drifted for "${g.name}"\n expected: ${EXPECTED_IF[g.name]}\n actual: ${actual}`, + ); + } +} +console.log('OK conditions in the workflow match the mirrored evaluators\n'); + +/** Mirror of the `if:` expressions (asserted above). */ +function applies(name, env) { + if (name === 'Guard — a stable tag must live on main') { + return env.GITHUB_REF_TYPE === 'tag' && !env.GITHUB_REF_NAME.includes('-'); + } + if (name === 'Guard — a manual publish must run from main') { + return env.GITHUB_EVENT_NAME === 'workflow_dispatch'; + } + throw new Error(`unknown guard: ${name}`); +} + +// ── 3. Scenarios against real commits ──────────────────────────────────────── +const MAIN = sh(['rev-parse', 'main']); +const DEV = sh(['rev-parse', 'dev']); +sh(['rev-parse', '--verify', 'origin/main']); // the guard needs this ref + +const scenarios = [ + { label: '1. stable tag ON main -> must PASS', refType: 'tag', refName: 'v3.9.5', sha: MAIN, event: 'push', expect: 'pass' }, + { label: '2. stable tag OFF main -> must FAIL', refType: 'tag', refName: 'v4.0.0', sha: DEV, event: 'push', expect: 'fail' }, + { label: '3. prerelease tag on dev -> guards SKIP', refType: 'tag', refName: 'v3.10.0-next.0', sha: DEV, event: 'push', expect: 'skip' }, + { label: '4. manual dispatch from dev -> must FAIL', refType: 'branch', refName: 'dev', sha: DEV, event: 'workflow_dispatch', expect: 'fail' }, + { label: '5. manual dispatch from main-> must PASS', refType: 'branch', refName: 'main', sha: MAIN, event: 'workflow_dispatch', expect: 'pass' }, +]; + +let failures = 0; +console.log(`main=${MAIN.slice(0, 7)} dev=${DEV.slice(0, 7)}\n`); + +for (const s of scenarios) { + const env = { + ...process.env, + GITHUB_REF_TYPE: s.refType, + GITHUB_REF_NAME: s.refName, + GITHUB_SHA: s.sha, + GITHUB_EVENT_NAME: s.event, + }; + + const active = guards.filter((g) => applies(g.name, env)); + let outcome = 'skip'; + let detail = 'no guard applies'; + + for (const g of active) { + const r = spawnSync('bash', ['-eo', 'pipefail', '-c', runBody(g)], { + cwd: repoRoot, + env, + encoding: 'utf8', + }); + if (r.status === 0) { + outcome = 'pass'; + detail = `ran "${g.name}"`; + } else { + outcome = 'fail'; + detail = + (r.stderr + r.stdout) + .trim() + .split('\n') + .find((l) => l.includes('::error::')) ?? `exit ${r.status}`; + break; + } + } + + const ok = outcome === s.expect; + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${s.label}`); + console.log(` got=${outcome} expected=${s.expect} | ${detail}`); +} + +console.log( + failures === 0 ? '\nAll scenarios behaved as required.' : `\n${failures} scenario(s) FAILED.`, +); +process.exit(failures === 0 ? 0 : 1); From 9965dcb1609f36a5bc51801ac05929c8292d9601 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 15:30:22 -0500 Subject: [PATCH 10/15] chore: release v3.10.0-next.0 --- CHANGELOG.md | 15 +++++++++++++++ package.json | 2 +- projects/ngx-theme-stack/package.json | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd1733..a6572ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +# [3.10.0-next.0](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.9.4...v3.10.0-next.0) (2026-10-05) + + +### Bug Fixes + +* **ci:** stop unstable code from reaching the latest dist-tag ([16fb4c4](https://github.com/WanderleeDev/ngx-theme-stack/commit/16fb4c45c776d0a9c0d081287b47cff32d1444be)) +* **deps:** pin jsdom's undici and drop the duplicate Angular copy ([5622c07](https://github.com/WanderleeDev/ngx-theme-stack/commit/5622c07453a8ee623dc8f335e74aed85e1944e33)) +* **lib:** return the merged theme list from provideThemeStack ([585e945](https://github.com/WanderleeDev/ngx-theme-stack/commit/585e945374faefb68aa11962c8e4bb1596304452)) +* **schematics:** keep object-form themes intact in sync ([f349eab](https://github.com/WanderleeDev/ngx-theme-stack/commit/f349eabfebef5fe52caa12d5774c52f690d7729d)) + + +### Features + +* **lib:** per-theme color-scheme hints for custom themes ([5c631ae](https://github.com/WanderleeDev/ngx-theme-stack/commit/5c631ae9ff0c1a75e5fa7201fcf7c5af55d632be)) + ## [3.9.4](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.9.3...v3.9.4) (2026-08-03) diff --git a/package.json b/package.json index a645387..5a393da 100644 --- a/package.json +++ b/package.json @@ -74,5 +74,5 @@ "typescript-eslint": "8.62.1", "vitest": "^4.1.11" }, - "version": "3.9.4" + "version": "3.10.0-next.0" } diff --git a/projects/ngx-theme-stack/package.json b/projects/ngx-theme-stack/package.json index af1ce35..bca6018 100644 --- a/projects/ngx-theme-stack/package.json +++ b/projects/ngx-theme-stack/package.json @@ -1,6 +1,6 @@ { "name": "ngx-theme-stack", - "version": "3.9.4", + "version": "3.10.0-next.0", "description": "A stack of themes for Angular applications.", "keywords": [ "angular", From 9c83a9c15ab5ff0909b4938ab836bea8c12fa1aa Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 17:06:49 -0500 Subject: [PATCH 11/15] fix(schematics): validate the color-scheme prompt and drop the duplicate none 'none' was a second spelling for 'auto': both call removeProperty('color-scheme'). The scheme was a prerelease-only value that never reached a stable release, so it is now removed: NgColorScheme = 'light' | 'dark' | 'auto' The scheme prompt validated nothing and buildProvideCall interpolated the answer verbatim, so typing 'banana' produced an app that does not compile: error TS2322: Type '"banana"' is not assignable to type 'NgColorScheme | undefined' Now the prompt lists light/dark/auto with what each does, states that anything else falls back to 'auto', and warns when it does. Membership is decided by a new parseColorScheme() in schematics/ng-add/constants.ts, duplicated from the library on purpose because schematics are CommonJS and cannot import the ESM library. Sync applies the same rule: an invalid scheme in an existing config is dropped so the entry behaves as scheme-less, and each dropped value is reported through a warning instead of being silently rewritten. Verified: 117 tests (was 110), tsc clean for the three configs, lint clean, build:lib and build:demo OK, sync regenerated the demo anti-flash script. --- .agents/skills/ngx-theme-stack/SKILL.md | 4 +- .../references/api-reference.md | 5 +- odd/tasks/color-scheme-validation.md | 81 +++++++++++++++++++ projects/demo-ngx-theme-stack/src/index.html | 2 +- projects/ngx-theme-stack/README.md | 5 +- .../schematics/ng-add/constants.ts | 31 +++++++ .../schematics/ng-add/index.ts | 41 +++++++--- .../ngx-theme-stack/schematics/skill/index.ts | 9 ++- .../ngx-theme-stack/schematics/sync/index.ts | 11 ++- .../schematics/utils/anti-flash-script.ts | 2 +- .../schematics/utils/theme-input.ts | 38 +++++++-- .../src/lib/core/core-theme.scheme.spec.ts | 13 --- .../src/lib/core/core-theme.service.ts | 2 +- projects/ngx-theme-stack/src/lib/types.ts | 12 +-- .../src/schematics/anti-flash-script.spec.ts | 15 +++- .../src/schematics/theme-input.spec.ts | 60 ++++++++++++++ 16 files changed, 279 insertions(+), 52 deletions(-) create mode 100644 odd/tasks/color-scheme-validation.md diff --git a/.agents/skills/ngx-theme-stack/SKILL.md b/.agents/skills/ngx-theme-stack/SKILL.md index c561c0c..d533796 100644 --- a/.agents/skills/ngx-theme-stack/SKILL.md +++ b/.agents/skills/ngx-theme-stack/SKILL.md @@ -18,13 +18,13 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (`ThemeCycleService`) - Rotate through all themes. - **Select** (`ThemeSelectService`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as light or dark, then declare it with the object form in `themes`: `{ name: 'sepia', scheme: 'light' }`. Without a `scheme`, a custom theme leaves scrollbars and form controls on the OS colour scheme. +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as `light` or `dark`, then declare it with the object form in `themes`: `{ name: 'sepia', scheme: 'light' }`. Answering `auto`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call `provideThemeStack()` once in root `app.config.ts`. Custom themes merge with defaults. -- `themes` accepts plain names and `{ name, scheme }` objects, mixable in one array. `scheme` is `'light' | 'dark' | 'auto' | 'none'` and only controls the CSS `color-scheme` hint for native widgets; it does not select a theme. Built-in names keep their implicit hint. +- `themes` accepts plain names and `{ name, scheme }` objects, mixable in one array. `scheme` is `'light' | 'dark' | 'auto'` and only controls the CSS `color-scheme` hint for native widgets; it does not select a theme. Any other value is invalid and behaves as `'auto'`. Built-in names keep their implicit hint. - **Theme Synchronization**: Syncs theme configuration in `app.config.ts` with `index.html` assets. - **Manual execution**: Run `pnpm run ngx-theme-stack:sync` (or `npm run ngx-theme-stack:sync` / `yarn run ngx-theme-stack:sync`). - **Auto-Sync**: Runs automatically before serving or building via `"prestart"` and `"prebuild"` hooks in `package.json`. diff --git a/.agents/skills/ngx-theme-stack/references/api-reference.md b/.agents/skills/ngx-theme-stack/references/api-reference.md index fd6627f..a76c85a 100644 --- a/.agents/skills/ngx-theme-stack/references/api-reference.md +++ b/.agents/skills/ngx-theme-stack/references/api-reference.md @@ -38,7 +38,10 @@ provideThemeStack({ | `'light'` | Sets `color-scheme: light` while the theme is active. | | `'dark'` | Sets `color-scheme: dark` while the theme is active. | | `'auto'` (default when omitted) | No hint; the browser decides. | -| `'none'` | Explicitly removes the hint. | + +Any other value is invalid and falls back to `'auto'`. The `ng-add` prompt only +accepts `light`, `dark`, or `auto`; an unknown answer falls back to `'auto'`. +The `sync` schematic drops an invalid `scheme` from an existing config and warns. Built-in names keep their implicit hint (`light` → light, `dark` → dark, `system` → auto). Prefer the object form for any custom theme whose native diff --git a/odd/tasks/color-scheme-validation.md b/odd/tasks/color-scheme-validation.md new file mode 100644 index 0000000..3d9b32b --- /dev/null +++ b/odd/tasks/color-scheme-validation.md @@ -0,0 +1,81 @@ +# Consolidate `auto` and fix the unvalidated scheme prompt + +## Context + +The `color-scheme` feature (see `theme-color-scheme.md`) shipped with two +defects that were only visible while testing it by hand: + +1. **`'none'` was a duplicate.** It was the same code path as `'auto'` (both call + `removeProperty('color-scheme')`), so it offered two spellings for one + behaviour. Since `3.10.0-next.0` is a prerelease and `'none'` never reached a + stable release, this was the moment to drop it. + +2. **The scheme prompt validated nothing.** It stored whatever the user typed, and + `buildProvideCall` interpolated it verbatim into `app.config.ts`. Typing + `banana` therefore produced an app that **does not compile**: + + ``` + error TS2322: Type '"banana"' is not assignable to type 'NgColorScheme | undefined' + ``` + +## Scope (approved) + +Consolidate `auto`, make the prompt explain the options and fall back to `auto`, +and make `sync` do the same. Items 2 and 3 from the audit (validating theme +*names*, and a runtime whitelist) were explicitly deferred. + +### Changes + +| Area | Change | +| --- | --- | +| `lib/types.ts` | `NgColorScheme = 'light' \| 'dark' \| 'auto'`. `'none'` removed. | +| `lib/core/core-theme.service.ts` | Hint removal now keyed on `scheme === 'auto'` only. | +| `schematics/ng-add/constants.ts` | New `COLOR_SCHEMES`, `DEFAULT_COLOR_SCHEME`, `parseColorScheme()`. Duplicated from the lib on purpose (schematics are CommonJS and cannot import the ESM library), with the same KEEP-IN-SYNC warning used for `DEFAULT_THEMES`. | +| `schematics/ng-add/index.ts` | The prompt lists `light`/`dark`/`auto` and what each does, states that anything else falls back to `'auto'`, and warns when it does. The `[y/N]` gate before it was removed. | +| `schematics/utils/theme-input.ts` | Validates `scheme`. An invalid value is dropped (the entry behaves as scheme-less) and reported via `invalidSchemes`. | +| `schematics/sync/index.ts` | Warns for each dropped invalid scheme instead of silently rewriting it. | +| `schematics/utils/anti-flash-script.ts` | Generated JS checks `sc==='auto'` only. | +| docs | README table, skill template, regenerated `.agents/**`, re-synced demo. | + +### Prompt flow change (flagging it) + +The previous flow asked `Do your custom themes need explicit color-scheme hints? +[y/N]` and **defaulted to no**, so most users never saw the explanation. That +gate was removed: the explanation is printed, then each custom theme is asked +once, and Enter accepts `auto`. Only custom themes are asked; built-ins keep +their implicit hint. Reverting to a gate is a one-line change if the extra +prompt is unwanted. + +## Verification + +- `pnpm exec ng test ngx-theme-stack --watch=false` → **117 passed** (was 110; + +7 for `parseColorScheme` and the invalid-scheme paths). +- `tsc --noEmit` clean for `tsconfig.lib.json`, `tsconfig.schematics.json`, and + `tsconfig.spec.json`. +- `pnpm exec ng lint ngx-theme-stack` clean. +- `pnpm run build:lib`, `pnpm run build:demo` OK. +- `pnpm run ngx-theme-stack:sync` regenerated the demo's `index.html`; its + anti-flash script now reads `sc==='auto'` and contains no `'none'`. +- `node scripts/verify-publish-guards.mjs` still passes. + +## Incident during verification: the stale Angular cache + +Bisecting with `git stash` / `git checkout --` produced a misleading result: the +library suite crashed the vitest worker with `Worker exited unexpectedly` and +**no** `Caused by` line, and it crashed even with only part of the change +applied. The cause was a corrupted `.angular/cache` incremental state left by +repeatedly reverting and restoring the same files. Deleting `.angular/cache` and +re-running passed 117 tests with the full change set. + +**Rule for this repo**: when bisecting library changes, remove `.angular/cache` +between runs, or the incremental build state will lie to you. + +## Deferred + +- **Item 2** — validate theme *names* against `/^[a-zA-Z][a-zA-Z0-9_-]*$/`, the + rule the anti-flash script and the runtime already enforce. A name like + `2lucky` or `lucky theme` is accepted by the schematic but makes the anti-flash + fall back to the default theme on first paint (a flash), and produces + `
` (two classes) in the critters trick. +- **Item 3** — runtime whitelist, so a hand-written config bypassing the types + cannot push an arbitrary string into `setProperty('color-scheme', ...)`. diff --git a/projects/demo-ngx-theme-stack/src/index.html b/projects/demo-ngx-theme-stack/src/index.html index 0a169f8..c599a64 100644 --- a/projects/demo-ngx-theme-stack/src/index.html +++ b/projects/demo-ngx-theme-stack/src/index.html @@ -5,7 +5,7 @@ - + ngx-theme-stack — Powerful Angular Theme Management diff --git a/projects/ngx-theme-stack/README.md b/projects/ngx-theme-stack/README.md index 3f81a5e..295ee1e 100644 --- a/projects/ngx-theme-stack/README.md +++ b/projects/ngx-theme-stack/README.md @@ -129,10 +129,9 @@ provideThemeStack({ |---------|-------------------------------------| | `'light'` | Sets `color-scheme: light` on the root element while the theme is active. | | `'dark'` | Sets `color-scheme: dark`. | -| `'auto'` | Removes the hint; the browser decides. | -| `'none'` | Explicitly removes the hint (legacy default for custom themes). | +| `'auto'` | No hint is set; the browser decides, usually from the OS. Also the default when `scheme` is omitted. | -The hint is applied to `` in the runtime and in the anti-flash script (before Angular boots), and is regenerated by the `sync` schematic. +Any other value is invalid and falls back to `'auto'`. The hint is applied to `` in the runtime and in the anti-flash script (before Angular boots), and is regenerated by the `sync` schematic — which drops an invalid `scheme` and warns about it instead of writing it back. --- diff --git a/projects/ngx-theme-stack/schematics/ng-add/constants.ts b/projects/ngx-theme-stack/schematics/ng-add/constants.ts index f3644af..96c34ef 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/constants.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/constants.ts @@ -10,6 +10,37 @@ */ export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; +/** + * Valid `color-scheme` hints for a custom theme. + * + * ⚠ KEEP IN SYNC with `NgColorScheme` in + * projects/ngx-theme-stack/src/lib/types.ts + * + * The library lost its `'none'` value on purpose: it was the same code path as + * `'auto'` (both simply remove the inline hint), so it only added a way to + * express the same thing twice. + */ +export const COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; +export type ColorScheme = (typeof COLOR_SCHEMES)[number]; + +/** The hint used when none is declared, or when the declared one is invalid. */ +export const DEFAULT_COLOR_SCHEME: ColorScheme = 'auto'; + +/** + * Maps user input to a valid {@link ColorScheme}, or `undefined` when the input + * does not match one of the accepted values. + * + * Matching is exact after trimming, so `'Light'` is rejected rather than guessed + * at: the prompt lists the accepted values, and an unknown one must not end up + * in the generated config as a type error. + */ +export function parseColorScheme(input: string): ColorScheme | undefined { + const value = input.trim(); + return (COLOR_SCHEMES as readonly string[]).includes(value) + ? (value as ColorScheme) + : undefined; +} + export const DEFAULTS = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', diff --git a/projects/ngx-theme-stack/schematics/ng-add/index.ts b/projects/ngx-theme-stack/schematics/ng-add/index.ts index 4db50d0..e57775c 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/index.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/index.ts @@ -1,7 +1,7 @@ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { patchIndexHtml } from './anti-flash'; import { patchAppConfig } from './app-config'; -import { DEFAULT_THEMES, DEFAULTS } from './constants'; +import { DEFAULT_COLOR_SCHEME, DEFAULT_THEMES, DEFAULTS, parseColorScheme } from './constants'; import { Schema } from './schema'; import { ask, askList, assertAngularProject, buildProvideCall, createRl, detectPackageManager } from './utils'; import { generateSkill } from '../skill/index'; @@ -61,20 +61,35 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise = {}; const customOnly = themes.filter((t) => t !== 'light' && t !== 'dark' && t !== 'system'); if (customOnly.length > 0) { - const wantScheme = await ask( - rl, - ` Do your custom themes need explicit color-scheme hints? [y/N] `, + process.stdout.write( + '\n A custom theme can declare the color-scheme used by native browser\n' + + ' widgets (scrollbars, inputs, form controls) while it is active:\n' + + " light -> color-scheme: light\n" + + " dark -> color-scheme: dark\n" + + ' auto -> no hint; the browser decides, usually from the OS\n' + + " Anything that is not one of those falls back to 'auto'.\n" + + " Built-in themes keep their own hint and are not asked about.\n\n", ); - if (wantScheme.toLowerCase() === 'y') { - for (const name of customOnly) { - const rawScheme = await ask( - rl, - ` color-scheme for "${name}" (light|dark|auto|none, Enter=auto): `, + + for (const name of customOnly) { + const rawScheme = await ask( + rl, + ` color-scheme for "${name}" (light|dark|auto) [auto]: `, + ); + + const parsed = rawScheme === '' ? DEFAULT_COLOR_SCHEME : parseColorScheme(rawScheme); + + if (parsed === undefined) { + process.stdout.write( + ` \u26a0 "${rawScheme}" is not a valid color-scheme \u2014 falling back to '${DEFAULT_COLOR_SCHEME}'.\n`, ); - const s = rawScheme.trim(); - if (s && s !== 'auto') { - schemeMap[name] = s; - } + continue; + } + + // 'auto' is the default behaviour of a scheme-less entry, so it is not + // written out: the theme stays a plain string and the output stays clean. + if (parsed !== DEFAULT_COLOR_SCHEME) { + schemeMap[name] = parsed; } } } diff --git a/projects/ngx-theme-stack/schematics/skill/index.ts b/projects/ngx-theme-stack/schematics/skill/index.ts index 2ac0ff5..5232e5f 100644 --- a/projects/ngx-theme-stack/schematics/skill/index.ts +++ b/projects/ngx-theme-stack/schematics/skill/index.ts @@ -25,13 +25,13 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (\`ThemeCycleService\`) - Rotate through all themes. - **Select** (\`ThemeSelectService\`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as light or dark, then declare it with the object form in \`themes\`: \`{ name: 'sepia', scheme: 'light' }\`. Without a \`scheme\`, a custom theme leaves scrollbars and form controls on the OS colour scheme. +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as \`light\` or \`dark\`, then declare it with the object form in \`themes\`: \`{ name: 'sepia', scheme: 'light' }\`. Answering \`auto\`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call \`provideThemeStack()\` once in root \`app.config.ts\`. Custom themes merge with defaults. -- \`themes\` accepts plain names and \`{ name, scheme }\` objects, mixable in one array. \`scheme\` is \`'light' | 'dark' | 'auto' | 'none'\` and only controls the CSS \`color-scheme\` hint for native widgets; it does not select a theme. Built-in names keep their implicit hint. +- \`themes\` accepts plain names and \`{ name, scheme }\` objects, mixable in one array. \`scheme\` is \`'light' | 'dark' | 'auto'\` and only controls the CSS \`color-scheme\` hint for native widgets; it does not select a theme. Any other value is invalid and behaves as \`'auto'\`. Built-in names keep their implicit hint. - **Theme Synchronization**: Syncs theme configuration in \`app.config.ts\` with \`index.html\` assets. - **Manual execution**: Run \`pnpm run ngx-theme-stack:sync\` (or \`npm run ngx-theme-stack:sync\` / \`yarn run ngx-theme-stack:sync\`). - **Auto-Sync**: Runs automatically before serving or building via \`"prestart"\` and \`"prebuild"\` hooks in \`package.json\`. @@ -213,7 +213,10 @@ provideThemeStack({ | \`'light'\` | Sets \`color-scheme: light\` while the theme is active. | | \`'dark'\` | Sets \`color-scheme: dark\` while the theme is active. | | \`'auto'\` (default when omitted) | No hint; the browser decides. | -| \`'none'\` | Explicitly removes the hint. | + +Any other value is invalid and falls back to \`'auto'\`. The \`ng-add\` prompt only +accepts \`light\`, \`dark\`, or \`auto\`; an unknown answer falls back to \`'auto'\`. +The \`sync\` schematic drops an invalid \`scheme\` from an existing config and warns. Built-in names keep their implicit hint (\`light\` → light, \`dark\` → dark, \`system\` → auto). Prefer the object form for any custom theme whose native diff --git a/projects/ngx-theme-stack/schematics/sync/index.ts b/projects/ngx-theme-stack/schematics/sync/index.ts index cf3d083..3d78830 100644 --- a/projects/ngx-theme-stack/schematics/sync/index.ts +++ b/projects/ngx-theme-stack/schematics/sync/index.ts @@ -1,7 +1,7 @@ import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { patchAppConfig } from '../ng-add/app-config'; import { assertAngularProject, buildProvideCall } from '../ng-add/utils'; -import { DEFAULTS } from '../ng-add/constants'; +import { COLOR_SCHEMES, DEFAULT_COLOR_SCHEME, DEFAULTS } from '../ng-add/constants'; import { Schema } from './schema'; import { buildAntiFlashScript } from '../utils/anti-flash-script'; import { parseThemeInputArray } from '../utils/theme-input'; @@ -102,9 +102,16 @@ function extractConfig( const strategy = OPTION_STRATEGY_RE.exec(opts)?.[1] ?? undefined; const themesRaw = OPTION_THEMES_RE.exec(opts)?.[1] ?? ''; - const { themes, schemeMap } = parseThemeInputArray(themesRaw); + const { themes, schemeMap, invalidSchemes } = parseThemeInputArray(themesRaw); if (themes.length === 0) themes.push(...DEFAULTS.themes); + for (const { name, scheme } of invalidSchemes) { + context.logger.warn( + `⚠ "${name}" declares scheme '${scheme}', which is not one of ` + + `[${COLOR_SCHEMES.join(', ')}]. Dropping the hint so the theme uses '${DEFAULT_COLOR_SCHEME}'.`, + ); + } + return { mode, strategy, diff --git a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts index 214f275..d2f345b 100644 --- a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts +++ b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts @@ -38,7 +38,7 @@ export function buildAntiFlashScript(options: AntiFlashScriptOptions): string { `if(m==='class'||m==='both')e.classList.add(t);` + `if(m==='attribute'||m==='both')e.setAttribute('data-theme',t);` + (schemeMap - ? `var sc=(s&&s[t])||'';if(sc==='auto'||sc==='none'){e.style.removeProperty('color-scheme');}else{e.style.setProperty('color-scheme',sc);}` + ? `var sc=(s&&s[t])||'';if(sc==='auto'){e.style.removeProperty('color-scheme');}else{e.style.setProperty('color-scheme',sc);}` : `if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);`) + `}catch(x){}})();` ); diff --git a/projects/ngx-theme-stack/schematics/utils/theme-input.ts b/projects/ngx-theme-stack/schematics/utils/theme-input.ts index 3c9f9e3..28bce68 100644 --- a/projects/ngx-theme-stack/schematics/utils/theme-input.ts +++ b/projects/ngx-theme-stack/schematics/utils/theme-input.ts @@ -9,12 +9,21 @@ * parentheses, or quotes). */ +import { parseColorScheme } from '../ng-add/constants'; + /** One parsed theme entry: its name and optional color-scheme hint. */ export interface ParsedThemeInput { name: string; + /** A valid scheme only. An invalid one is dropped and reported separately. */ scheme?: string; } +/** A scheme found in the config that is not one of the accepted values. */ +export interface InvalidScheme { + name: string; + scheme: string; +} + /** * Splits the body of a `themes: [...]` array into top-level entries. * @@ -60,12 +69,25 @@ export function splitTopLevelEntries(body: string): string[] { /** * Parses one theme entry, either `'name'` or `{ name: 'n', scheme: 's' }`. * Property order inside the object is not significant. + * + * A scheme outside the accepted values is dropped, so the entry behaves as if + * no hint had been declared instead of being written back as a type error. The + * dropped value is reported through `droppedScheme` for the caller to warn about. */ -export function parseThemeEntry(entry: string): ParsedThemeInput | null { +export function parseThemeEntry( + entry: string, +): (ParsedThemeInput & { droppedScheme?: string }) | null { if (entry.startsWith('{')) { const name = /name\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; - const scheme = /scheme\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; - return name ? { name, scheme } : null; + if (!name) return null; + + const rawScheme = /scheme\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; + if (rawScheme === undefined) return { name }; + + const scheme = parseColorScheme(rawScheme); + return scheme === undefined + ? { name, droppedScheme: rawScheme } + : { name, scheme }; } const match = /^['"]([^'"]+)['"]$/.exec(entry); return match ? { name: match[1] } : null; @@ -78,16 +100,22 @@ export function parseThemeEntry(entry: string): ParsedThemeInput | null { export function parseThemeInputArray(body: string): { themes: string[]; schemeMap: Record; + invalidSchemes: InvalidScheme[]; } { const themes: string[] = []; const schemeMap: Record = {}; + const invalidSchemes: InvalidScheme[] = []; for (const entry of splitTopLevelEntries(body)) { const parsed = parseThemeEntry(entry); if (!parsed) continue; themes.push(parsed.name); - if (parsed.scheme) schemeMap[parsed.name] = parsed.scheme; + if (parsed.droppedScheme !== undefined) { + invalidSchemes.push({ name: parsed.name, scheme: parsed.droppedScheme }); + } else if (parsed.scheme) { + schemeMap[parsed.name] = parsed.scheme; + } } - return { themes, schemeMap }; + return { themes, schemeMap, invalidSchemes }; } diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts index c38ffbb..9072d57 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts @@ -92,19 +92,6 @@ describe('CoreThemeService — per-theme color-scheme hints', () => { expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); }); - it('removes color-scheme when custom theme scheme is none', () => { - const { service } = setup({ - themes: ['light', 'dark', 'system', { name: 'paper', scheme: 'none' } as NgThemeInput], - }); - service.setTheme('dark'); - TestBed.tick(); - expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); - - service.setTheme('paper'); - TestBed.tick(); - expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); - }); - it('keeps the implicit hint for built-in light/dark', () => { const { service } = setup({ themes: ['light', 'dark', 'system'] }); service.setTheme('light'); diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts index da073a4..fefc285 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts @@ -239,7 +239,7 @@ export class CoreThemeService { // by `resolvedTheme`. Custom themes carry the user-declared scheme hint // (default: 'auto', no change). const scheme = this.#schemeByName.get(theme) ?? 'auto'; - if (scheme === 'auto' || scheme === 'none') { + if (scheme === 'auto') { host.style.removeProperty('color-scheme'); return; } diff --git a/projects/ngx-theme-stack/src/lib/types.ts b/projects/ngx-theme-stack/src/lib/types.ts index be00d53..ca3664d 100644 --- a/projects/ngx-theme-stack/src/lib/types.ts +++ b/projects/ngx-theme-stack/src/lib/types.ts @@ -40,14 +40,15 @@ export type NgSystemTheme = Exclude; * Color scheme hint applied to the root element via `color-scheme`. * * - `'light'` / `'dark'` — forces the browser to use matching native UI widgets. - * - `'auto'` — no explicit hint; the browser chooses based on surrounding styles. - * - `'none'` — explicitly removes any inherited `color-scheme` (legacy default). + * - `'auto'` — no explicit hint; the browser chooses, usually from the OS. + * + * An unknown value is not a valid hint: the schematic falls back to `'auto'`. * * This value is forwarded to `document.documentElement.style.color-scheme`. * It only affects how the browser paints native widgets (scrollbars, inputs, * form controls) in that color scheme — it does not pick a theme for you. */ -export type NgColorScheme = 'light' | 'dark' | 'auto' | 'none'; +export type NgColorScheme = 'light' | 'dark' | 'auto'; /** * Declarative theme entry. @@ -67,9 +68,10 @@ export interface NgThemeOption { name: string; /** * Optional color-scheme hint for native UI widgets when this theme is active. - * - Omitted (or `'auto'`): the library will not set a `color-scheme` hint. + * - Omitted (or `'auto'`): no hint is set; the browser decides. * - `'light'` / `'dark'`: hint is set on the root element while the theme is active. - * - `'none'`: explicitly removed. + * + * Any other value is invalid and is treated as `'auto'`. */ scheme?: NgColorScheme; } diff --git a/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts b/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts index 6735352..2bbc1b1 100644 --- a/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts +++ b/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts @@ -37,7 +37,7 @@ describe('buildAntiFlashScript', () => { expect(script).toContain('v=["system","light","dark","sepia"]'); }); - it('removes color-scheme when scheme is auto or none', () => { + it('removes color-scheme when the scheme is auto', () => { const script = buildAntiFlashScript({ storageKey: 'key', defaultTheme: 'system', @@ -46,7 +46,18 @@ describe('buildAntiFlashScript', () => { schemeMap: { sepia: 'auto' }, }); expect(script).toContain( - "if(sc==='auto'||sc==='none'){e.style.removeProperty('color-scheme');}", + "if(sc==='auto'){e.style.removeProperty('color-scheme');}", ); }); + + it('never emits a none branch, because none is no longer a valid scheme', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'system', + mode: 'class', + themes: ['system', 'light', 'dark'], + schemeMap: { sepia: 'auto' }, + }); + expect(script).not.toContain("'none'"); + }); }); diff --git a/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts index 698485b..02cea94 100644 --- a/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts +++ b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts @@ -3,6 +3,32 @@ import { parseThemeInputArray, splitTopLevelEntries, } from '../../schematics/utils/theme-input'; +import { COLOR_SCHEMES, parseColorScheme } from '../../schematics/ng-add/constants'; + +describe('parseColorScheme', () => { + it('accepts every declared scheme', () => { + for (const scheme of COLOR_SCHEMES) { + expect(parseColorScheme(scheme)).toBe(scheme); + } + }); + + it('rejects anything outside the declared list', () => { + expect(parseColorScheme('banana')).toBeUndefined(); + expect(parseColorScheme('Light')).toBeUndefined(); + expect(parseColorScheme('lightt')).toBeUndefined(); + expect(parseColorScheme('')).toBeUndefined(); + expect(parseColorScheme(' ')).toBeUndefined(); + }); + + it('rejects none, which was removed on purpose', () => { + expect(COLOR_SCHEMES).not.toContain('none'); + expect(parseColorScheme('none')).toBeUndefined(); + }); + + it('trims before matching', () => { + expect(parseColorScheme(' dark ')).toBe('dark'); + }); +}); describe('splitTopLevelEntries', () => { it('splits plain string entries', () => { @@ -73,6 +99,7 @@ describe('parseThemeInputArray', () => { expect(parseThemeInputArray(body)).toEqual({ themes: ['system', 'light', 'dark', 'sunset', 'sepia'], schemeMap: { sunset: 'dark', sepia: 'light' }, + invalidSchemes: [], }); }); @@ -80,6 +107,7 @@ describe('parseThemeInputArray', () => { expect(parseThemeInputArray("'system', 'light', 'dark'")).toEqual({ themes: ['system', 'light', 'dark'], schemeMap: {}, + invalidSchemes: [], }); }); @@ -96,6 +124,38 @@ describe('parseThemeInputArray', () => { expect(parseThemeInputArray(body)).toEqual({ themes: ['light', 'dark', 'sepia'], schemeMap: { sepia: 'light' }, + invalidSchemes: [], + }); + }); + + it('drops an invalid scheme and reports it instead of failing', () => { + const body = + "'light', { name: 'lucky', scheme: 'banana' }, { name: 'sunset', scheme: 'dark' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'lucky', 'sunset'], + schemeMap: { sunset: 'dark' }, + invalidSchemes: [{ name: 'lucky', scheme: 'banana' }], + }); + }); + + it('drops a legacy none scheme and reports it', () => { + const body = "'light', { name: 'paper', scheme: 'none' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'paper'], + schemeMap: {}, + invalidSchemes: [{ name: 'paper', scheme: 'none' }], + }); + }); + + it('keeps an explicit auto untouched', () => { + const body = "'light', { name: 'paper', scheme: 'auto' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'paper'], + schemeMap: { paper: 'auto' }, + invalidSchemes: [], }); }); }); From 5c9044e678de0d169c497932cbab22643274a5c1 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 20:37:11 -0500 Subject: [PATCH 12/15] feat(lib): enforce theme names as CSS identifiers, and generate the schematic copies Nothing validated a theme name: provideThemeStack only checked for empty, while types.ts documented one pattern and the anti-flash script enforced another. A bad name degraded silently: 2lucky .2lucky is invalid CSS, so the browser drops the rule and the theme loses its styles lucky theme classList.add throws InvalidCharacterError lucky.theme .lucky.theme matches two classes while the class token is the single string 'lucky.theme', so the styles never apply, silently The rule becomes /^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ and is now enforced. It also accepts _lucky and -lucky, which are valid CSS but the old pattern rejected, causing an unnecessary first-paint flash. Non-ASCII names stay rejected on purpose: a documented narrower subset, not an accident. provideThemeStack() throws NgxThemeStackError, the ng-add prompt re-asks with the reason, and sync reports invalid names it finds in an existing config. The anti-flash inline script keeps its own check. It was proposed to drop it as redundant with v.indexOf(t) !== -1, but the value comes from localStorage and the list it is compared against is a hand-editable array in index.html; it runs before Angular and writes to classList and to an attribute. It now uses the same pattern, so it no longer flashes _lucky / -lucky. The values the schematics share with the library were duplicated behind KEEP IN SYNC comments, which is a promise rather than a mechanism. A single source file cannot belong to both compile units, because the schematics use rootDir: "schematics" with CommonJS while ng-packagr builds ESM from src/: error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics' scripts/generate-schematic-constants.mjs now generates schematics/ng-add/generated-config.ts from the library, wired to prebuild. It reads the TypeScript AST rather than matching text, exposes the pattern as a string that both sides compile with new RegExp(...), and replaces four duplicated value sets (DEFAULT_THEMES, DEFAULTS, COLOR_SCHEMES, the name pattern). src/schematics/generated-config.spec.ts fails when the copy goes stale; that was verified by tampering with the generated file and watching the suite fail. Verified: 138 tests (was 117), tsc clean for the three configs, lint clean, build:lib and build:demo OK, publish guards 5/5, generator idempotent, and the demo's re-synced inline script uses the new pattern. --- .agents/skills/ngx-theme-stack/SKILL.md | 3 +- odd/tasks/theme-name-validation.md | 116 ++++++++++++ projects/demo-ngx-theme-stack/src/index.html | 2 +- projects/ngx-theme-stack/README.md | 27 +++ projects/ngx-theme-stack/package.json | 2 + .../schematics/ng-add/constants.ts | 72 ++++++-- .../schematics/ng-add/generated-config.ts | 26 +++ .../schematics/ng-add/index.ts | 51 ++++-- .../ngx-theme-stack/schematics/skill/index.ts | 3 +- .../ngx-theme-stack/schematics/sync/index.ts | 12 +- .../schematics/utils/anti-flash-script.ts | 9 +- .../schematics/utils/theme-input.ts | 7 +- .../src/lib/config/index.spec.ts | 21 +++ .../ngx-theme-stack/src/lib/config/index.ts | 16 +- .../src/lib/core/core-theme.service.ts | 4 +- .../src/lib/types.theme-scheme.spec.ts | 41 +++++ projects/ngx-theme-stack/src/lib/types.ts | 47 ++++- .../src/schematics/generated-config.spec.ts | 101 +++++++++++ .../src/schematics/theme-input.spec.ts | 17 ++ scripts/generate-schematic-constants.mjs | 166 ++++++++++++++++++ 20 files changed, 701 insertions(+), 42 deletions(-) create mode 100644 odd/tasks/theme-name-validation.md create mode 100644 projects/ngx-theme-stack/schematics/ng-add/generated-config.ts create mode 100644 projects/ngx-theme-stack/src/schematics/generated-config.spec.ts create mode 100755 scripts/generate-schematic-constants.mjs diff --git a/.agents/skills/ngx-theme-stack/SKILL.md b/.agents/skills/ngx-theme-stack/SKILL.md index d533796..e505255 100644 --- a/.agents/skills/ngx-theme-stack/SKILL.md +++ b/.agents/skills/ngx-theme-stack/SKILL.md @@ -18,13 +18,14 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (`ThemeCycleService`) - Rotate through all themes. - **Select** (`ThemeSelectService`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as `light` or `dark`, then declare it with the object form in `themes`: `{ name: 'sepia', scheme: 'light' }`. Answering `auto`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). Suggested names must match `/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/` (ASCII, no leading digit, no whitespace, no CSS metacharacter). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as `light` or `dark`, then declare it with the object form in `themes`: `{ name: 'sepia', scheme: 'light' }`. Answering `auto`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call `provideThemeStack()` once in root `app.config.ts`. Custom themes merge with defaults. - `themes` accepts plain names and `{ name, scheme }` objects, mixable in one array. `scheme` is `'light' | 'dark' | 'auto'` and only controls the CSS `color-scheme` hint for native widgets; it does not select a theme. Any other value is invalid and behaves as `'auto'`. Built-in names keep their implicit hint. +- **Theme names must match `/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/`** (ASCII only). A name becomes a CSS class and is written into `classList`, so a leading digit makes `.2lucky` invalid CSS (the browser drops the rule and the theme loses its styles), ASCII whitespace throws `InvalidCharacterError` in `classList.add`, and a CSS metacharacter turns `.lucky.theme` into a two-class selector that can never match the single class token `lucky.theme` — silently. `provideThemeStack()` throws `NgxThemeStackError` for a bad name; pick a name in that shape before writing config. - **Theme Synchronization**: Syncs theme configuration in `app.config.ts` with `index.html` assets. - **Manual execution**: Run `pnpm run ngx-theme-stack:sync` (or `npm run ngx-theme-stack:sync` / `yarn run ngx-theme-stack:sync`). - **Auto-Sync**: Runs automatically before serving or building via `"prestart"` and `"prebuild"` hooks in `package.json`. diff --git a/odd/tasks/theme-name-validation.md b/odd/tasks/theme-name-validation.md new file mode 100644 index 0000000..fec476d --- /dev/null +++ b/odd/tasks/theme-name-validation.md @@ -0,0 +1,116 @@ +# Theme name validation, and centralizing the values shared with the schematics + +## Context + +Two things surfaced while testing the `color-scheme` prerelease by hand. + +**1. Nothing validated a theme name.** `provideThemeStack()` only checked that a +name was not empty, while `types.ts` documented a pattern and the anti-flash +script enforced a *different* one. Declaring a bad name degraded silently: + +| Name | What actually happened | +| --- | --- | +| `2lucky` | `.2lucky` is invalid CSS (esbuild: `Unexpected ".2lucky"`), the browser drops the rule, and the theme silently loses its styles | +| `lucky theme` | `classList.add` throws `InvalidCharacterError` | +| `lucky.theme` | `.lucky.theme` matches two classes while the class token is the single string `lucky.theme`, so the styles never apply — with no error at all | + +**2. The shared values were duplicated with a "KEEP IN SYNC" comment**, which is a +promise rather than a mechanism. There were four copies: `DEFAULT_THEMES`, +`DEFAULTS`, `COLOR_SCHEMES`, and now the theme-name pattern. + +## Why the duplication existed + +Not laziness — a hard compile-unit boundary, measured with a probe: + +``` +error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics'. + 'rootDir' is expected to contain all source files. +``` + +| Unit | Config | Output | +| --- | --- | --- | +| Schematics | `rootDir: "schematics"`, `module: commonjs` | `dist/.../schematics/` | +| Library | ng-packagr from `src/public-api.ts` | `dist/.../fesm2022/` (ESM) | + +One source file cannot belong to both. + +## What was done + +### The rule (unchanged policy: ASCII-only) + +``` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +Two fixes over the old `/^[a-zA-Z][a-zA-Z0-9_-]*$/`: + +- it accepts `_lucky` and `-lucky`, which are valid CSS and worked in + `classList`, but the old pattern rejected (causing an unnecessary flash); +- it is now **enforced**, so what used to be a silent runtime degradation is a + build error. + +Non-ASCII names (`sueño`, `café`) stay rejected **on purpose**: a narrower, +easy-to-reason-about subset. They are valid CSS, so this is a documented policy +choice, not a bug. The README and the skill say so explicitly. + +### Enforcement + +| Where | Behaviour | +| --- | --- | +| `provideThemeStack()` | throws `NgxThemeStackError` with the pattern and the reason | +| `ng-add` prompt | re-asks the whole list, printing why each name was rejected | +| `sync` | reports invalid names through `parseThemeInputArray().invalidNames` | +| anti-flash inline script | keeps validating — see below | + +### The inline guard was kept, not simplified + +An earlier proposal was to drop the anti-flash regex as redundant, since that +line also checks `v.indexOf(t) !== -1`. That was rejected, correctly: the value +comes from `localStorage`, which anyone with browser access can write, and the +declared list it is compared against is a plain, hand-editable array in +`index.html`. It runs before Angular and writes to `classList` and to an +attribute, so it validates on its own. Defence in depth, ~30 bytes. It now uses +the same pattern, so it no longer flashes `_lucky` / `-lucky`. + +### Centralization (replaces the "KEEP IN SYNC" comments) + +`scripts/generate-schematic-constants.mjs` copies the library-owned values into +`schematics/ng-add/generated-config.ts`. The library is the source of truth; the +schematic copy is generated. + +- Extraction uses the **TypeScript compiler API**, not regular expressions, so it + does not break on formatting, comments or trailing commas. +- The canonical values are exposed as **strings and string arrays** + (`NG_COLOR_SCHEMES`, `THEME_NAME_SOURCE`), and both sides build their `RegExp` + with `new RegExp(...)`. That removes the whole class of escaping bugs from the + inlined script. +- Wired to `prebuild`, so `pnpm run build` (the schematics build) regenerates + first and the copy cannot go stale. +- The generated file is **committed** (CI runs tests before `build:lib`, so a test + importing it must find it without a prior build) and carries an + AUTO-GENERATED header. + +One mechanism replaces four duplicated value sets. + +## Verification + +- **138 tests pass** (was 117). +- `tsc --noEmit` clean for `tsconfig.lib.json`, `tsconfig.schematics.json`, + `tsconfig.spec.json`. +- `ng lint ngx-theme-stack` clean; `build:lib` and `build:demo` OK; publish + guards 5/5. +- The generator is idempotent (`unchanged` on a second run). +- **The drift test was verified by breaking it**: the generated pattern was + tampered with by hand and the suite failed with 2 errors (`copies the theme + name pattern source` and `agrees with the library on the names it accepts`). + A drift test that never fails is worthless, so this was checked on purpose. +- The demo's `index.html` was re-synced and its inline script now reads + `if(!/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/.test(t)`, proving the + generator → constants → inline-script chain end to end. + +## Follow-ups + +- `item 3` from the earlier audit is now covered for names; a runtime whitelist + for `scheme` values in a hand-written config is still open. +- The `[Y/n]` prompt for the agent skill treats any input other than `n` as yes. + Inconsistent with the other prompts, and untouched. diff --git a/projects/demo-ngx-theme-stack/src/index.html b/projects/demo-ngx-theme-stack/src/index.html index c599a64..884f5bf 100644 --- a/projects/demo-ngx-theme-stack/src/index.html +++ b/projects/demo-ngx-theme-stack/src/index.html @@ -5,7 +5,7 @@ - + ngx-theme-stack — Powerful Angular Theme Management diff --git a/projects/ngx-theme-stack/README.md b/projects/ngx-theme-stack/README.md index 295ee1e..cd4ba90 100644 --- a/projects/ngx-theme-stack/README.md +++ b/projects/ngx-theme-stack/README.md @@ -135,6 +135,33 @@ Any other value is invalid and falls back to `'auto'`. The hint is applied to `< --- +## 🏷️ Theme Names + +A theme name becomes a CSS class (`.name`) in class mode and is written into +`classList`, so it has to be usable as a CSS identifier. Names must match: + +``` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +| Name | Accepted | Why | +|------|----------|-----| +| `lucky`, `lucky-theme`, `lucky_theme`, `lucky2`, `_lucky`, `-lucky` | ✅ | usable as-is | +| `2lucky` | ❌ | `.2lucky` is invalid CSS, so the browser would drop the whole rule and the theme would silently lose its styles | +| `lucky theme` | ❌ | `classList.add` throws `InvalidCharacterError` on ASCII whitespace | +| `lucky.theme` | ❌ | `.lucky.theme` matches two classes, but the class token is the single string `lucky.theme`, so the styles would never apply — with no error at all | +| `sueño`, `café` | ❌ | deliberately ASCII-only. Valid CSS, but a narrower subset is easier to reason about; rejected on purpose, not by accident | + +`provideThemeStack()` throws `NgxThemeStackError` for a name that does not match, +so a bad name is a build error instead of a silent first-paint flash. The +`ng-add` prompt re-asks with the reason, and `sync` reports invalid names it finds +in an existing config. + +> The same pattern is inline in the anti-flash script, which validates the +> untrusted `localStorage` value before writing it to the DOM. + +--- + ## 📚 Advanced Guides & Documentation For details on more advanced topics, check out the official guides: diff --git a/projects/ngx-theme-stack/package.json b/projects/ngx-theme-stack/package.json index bca6018..753f5b7 100644 --- a/projects/ngx-theme-stack/package.json +++ b/projects/ngx-theme-stack/package.json @@ -22,6 +22,8 @@ }, "homepage": "https://ngx-theme-stack-docs.wanderlee.site/", "scripts": { + "generate:constants": "node ../../scripts/generate-schematic-constants.mjs", + "prebuild": "npm run generate:constants", "build": "tsc -p tsconfig.schematics.json", "postbuild": "copyfiles schematics/*/schema.json schematics/*/files/** schematics/collection.json ../../dist/ngx-theme-stack/ && node -e \"const f='../../dist/ngx-theme-stack/package.json',p=JSON.parse(require('fs').readFileSync(f,'utf8'));delete p.type;require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\\n');\"" }, diff --git a/projects/ngx-theme-stack/schematics/ng-add/constants.ts b/projects/ngx-theme-stack/schematics/ng-add/constants.ts index 96c34ef..2246bfb 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/constants.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/constants.ts @@ -1,26 +1,65 @@ /** - * ⚠ ATTENTION: SHARED CONFIGURATION VALUES + * Values and helpers used by the schematics. * - * These values MUST match the library defaults in: - * - projects/ngx-theme-stack/src/lib/types.ts → DEFAULT_THEMES - * - projects/ngx-theme-stack/src/lib/config/index.ts → DEFAULT_NG_CONFIG + * The values that the library also owns live in ./generated-config.ts, which is + * produced by scripts/generate-schematic-constants.mjs. The schematics compile to + * CommonJS with `rootDir: "schematics"` and cannot import the ESM library, so the + * build copies those values instead of relying on a "KEEP IN SYNC" comment: * - * Schematics run in Node.js (CommonJS) and cannot import from the library (ESM), - * so the values are intentionally duplicated. Change all three at the same time. + * error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics' + * + * Everything below is derived from that generated file, or is schematic-only. */ -export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; +import { + DEFAULT_NG_CONFIG, + DEFAULT_THEMES as GENERATED_DEFAULT_THEMES, + NG_COLOR_SCHEMES, + THEME_NAME_SOURCE, +} from './generated-config'; + +export const DEFAULT_THEMES = GENERATED_DEFAULT_THEMES; /** - * Valid `color-scheme` hints for a custom theme. + * Pattern a theme identifier must match to be usable as a CSS class. * - * ⚠ KEEP IN SYNC with `NgColorScheme` in - * projects/ngx-theme-stack/src/lib/types.ts + * Why each part matters is documented on the library copy + * (src/lib/types.ts → THEME_NAME_SOURCE). In short: a leading digit breaks the + * class selector, an ASCII space throws in `classList.add`, and a CSS + * metacharacter turns `.lucky.theme` into a two-class selector that can never + * match the single class token `lucky.theme`. + * + * The anti-flash script inlines this same pattern, because it runs before + * anything else and writes an untrusted localStorage value into the DOM. It is + * defence in depth: the value is also checked against the declared list. + */ +export const THEME_NAME_PATTERN = new RegExp(THEME_NAME_SOURCE); + +/** Whether `name` is usable as a theme identifier. See {@link THEME_NAME_PATTERN}. */ +export function isValidThemeName(name: string): boolean { + return THEME_NAME_PATTERN.test(name); +} + +/** Explains why a name was rejected, so prompts and warnings can be actionable. */ +export function describeInvalidThemeName(name: string): string { + if (name.trim() === '') return 'it is empty'; + if (/^[0-9]/.test(name)) return 'it starts with a digit, which is not a valid CSS class'; + // Only ASCII whitespace throws in classList.add; anything else non-ASCII falls + // through to the allowed-set message below, which is the accurate reason. + if (/[\t\n\f\r ]/.test(name)) + return 'it contains whitespace, which throws in classList.add'; + if (/[.#>+~[\]:*(),]/.test(name)) + return 'it contains a CSS metacharacter, which changes what the selector matches'; + return 'it contains a character outside the allowed set'; +} + +/** + * Valid `color-scheme` hints for a custom theme. * - * The library lost its `'none'` value on purpose: it was the same code path as - * `'auto'` (both simply remove the inline hint), so it only added a way to - * express the same thing twice. + * Derived from the library's NgColorScheme. That union lost its `'none'` value on + * purpose: it was the same code path as `'auto'` (both simply remove the inline + * hint), so it only added a way to express the same thing twice. */ -export const COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; +export const COLOR_SCHEMES = NG_COLOR_SCHEMES; export type ColorScheme = (typeof COLOR_SCHEMES)[number]; /** The hint used when none is declared, or when the declared one is invalid. */ @@ -42,9 +81,6 @@ export function parseColorScheme(input: string): ColorScheme | undefined { } export const DEFAULTS = { - defaultTheme: 'system', - storageKey: 'ngx-theme-stack', - mode: 'class', - strategy: 'critters', + ...DEFAULT_NG_CONFIG, themes: [...DEFAULT_THEMES], } as const; diff --git a/projects/ngx-theme-stack/schematics/ng-add/generated-config.ts b/projects/ngx-theme-stack/schematics/ng-add/generated-config.ts new file mode 100644 index 0000000..a9cc74e --- /dev/null +++ b/projects/ngx-theme-stack/schematics/ng-add/generated-config.ts @@ -0,0 +1,26 @@ +/** + * AUTO-GENERATED by scripts/generate-schematic-constants.mjs — DO NOT EDIT. + * + * Source of truth: + * - src/lib/types.ts (DEFAULT_THEMES, NG_COLOR_SCHEMES, THEME_NAME_SOURCE) + * - src/lib/config/index.ts (DEFAULT_NG_CONFIG) + * + * The schematics compile to CommonJS with `rootDir: "schematics"` and cannot + * import the ESM library, so the build copies these values here. Regenerate with + * `pnpm --filter ngx-theme-stack run generate:constants` (also runs on prebuild). + * + * src/schematics/generated-config.spec.ts asserts these still match the library. + */ + +export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; + +export const NG_COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; + +export const THEME_NAME_SOURCE = '^-?[a-zA-Z_][a-zA-Z0-9_-]*$'; + +export const DEFAULT_NG_CONFIG = { + defaultTheme: 'system', + storageKey: 'ngx-theme-stack', + mode: 'class', + strategy: 'critters', +} as const; diff --git a/projects/ngx-theme-stack/schematics/ng-add/index.ts b/projects/ngx-theme-stack/schematics/ng-add/index.ts index e57775c..73e16ee 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/index.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/index.ts @@ -1,7 +1,15 @@ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { patchIndexHtml } from './anti-flash'; import { patchAppConfig } from './app-config'; -import { DEFAULT_COLOR_SCHEME, DEFAULT_THEMES, DEFAULTS, parseColorScheme } from './constants'; +import { + DEFAULT_COLOR_SCHEME, + DEFAULT_THEMES, + DEFAULTS, + describeInvalidThemeName, + isValidThemeName, + parseColorScheme, + THEME_NAME_PATTERN, +} from './constants'; import { Schema } from './schema'; import { ask, askList, assertAngularProject, buildProvideCall, createRl, detectPackageManager } from './utils'; import { generateSkill } from '../skill/index'; @@ -23,14 +31,38 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise t.trim()) - .filter(Boolean) - : []; - const themes = [...DEFAULT_THEMES, ...customThemes]; + let customThemes: string[] = []; + for (;;) { + const rawThemes = await ask(rl, ' Custom themes (comma-separated, Enter to skip): '); + const candidates = rawThemes + ? rawThemes + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : []; + + // A theme name becomes a CSS class (`.name`) and is written into + // classList, so a bad one used to produce a silent first-paint flash, or a + // theme whose styles never matched. Reject it here, with the reason. + const invalid = candidates.filter((name) => !isValidThemeName(name)); + if (invalid.length > 0) { + for (const bad of invalid) { + process.stdout.write( + ` \u26a0 "${bad}" is not a valid theme name: ${describeInvalidThemeName(bad)}.\n`, + ); + } + process.stdout.write( + ` A theme name must match ${THEME_NAME_PATTERN.source}. Please try again.\n\n`, + ); + continue; + } + + customThemes = [...new Set(candidates)]; + break; + } + + const customOnly = customThemes.filter((t) => !DEFAULT_THEMES.includes(t as never)); + const themes = [...DEFAULT_THEMES, ...customOnly]; const defaultTheme = await askList(rl, 'Default theme:', themes, 0); @@ -59,7 +91,6 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise = {}; - const customOnly = themes.filter((t) => t !== 'light' && t !== 'dark' && t !== 'system'); if (customOnly.length > 0) { process.stdout.write( '\n A custom theme can declare the color-scheme used by native browser\n' + diff --git a/projects/ngx-theme-stack/schematics/skill/index.ts b/projects/ngx-theme-stack/schematics/skill/index.ts index 5232e5f..f5be54f 100644 --- a/projects/ngx-theme-stack/schematics/skill/index.ts +++ b/projects/ngx-theme-stack/schematics/skill/index.ts @@ -25,13 +25,14 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (\`ThemeCycleService\`) - Rotate through all themes. - **Select** (\`ThemeSelectService\`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as \`light\` or \`dark\`, then declare it with the object form in \`themes\`: \`{ name: 'sepia', scheme: 'light' }\`. Answering \`auto\`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). Suggested names must match \`/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/\` (ASCII, no leading digit, no whitespace, no CSS metacharacter). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as \`light\` or \`dark\`, then declare it with the object form in \`themes\`: \`{ name: 'sepia', scheme: 'light' }\`. Answering \`auto\`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call \`provideThemeStack()\` once in root \`app.config.ts\`. Custom themes merge with defaults. - \`themes\` accepts plain names and \`{ name, scheme }\` objects, mixable in one array. \`scheme\` is \`'light' | 'dark' | 'auto'\` and only controls the CSS \`color-scheme\` hint for native widgets; it does not select a theme. Any other value is invalid and behaves as \`'auto'\`. Built-in names keep their implicit hint. +- **Theme names must match \`/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/\`** (ASCII only). A name becomes a CSS class and is written into \`classList\`, so a leading digit makes \`.2lucky\` invalid CSS (the browser drops the rule and the theme loses its styles), ASCII whitespace throws \`InvalidCharacterError\` in \`classList.add\`, and a CSS metacharacter turns \`.lucky.theme\` into a two-class selector that can never match the single class token \`lucky.theme\` — silently. \`provideThemeStack()\` throws \`NgxThemeStackError\` for a bad name; pick a name in that shape before writing config. - **Theme Synchronization**: Syncs theme configuration in \`app.config.ts\` with \`index.html\` assets. - **Manual execution**: Run \`pnpm run ngx-theme-stack:sync\` (or \`npm run ngx-theme-stack:sync\` / \`yarn run ngx-theme-stack:sync\`). - **Auto-Sync**: Runs automatically before serving or building via \`"prestart"\` and \`"prebuild"\` hooks in \`package.json\`. diff --git a/projects/ngx-theme-stack/schematics/sync/index.ts b/projects/ngx-theme-stack/schematics/sync/index.ts index 3d78830..af0a4c3 100644 --- a/projects/ngx-theme-stack/schematics/sync/index.ts +++ b/projects/ngx-theme-stack/schematics/sync/index.ts @@ -1,7 +1,7 @@ import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { patchAppConfig } from '../ng-add/app-config'; import { assertAngularProject, buildProvideCall } from '../ng-add/utils'; -import { COLOR_SCHEMES, DEFAULT_COLOR_SCHEME, DEFAULTS } from '../ng-add/constants'; +import { COLOR_SCHEMES, DEFAULT_COLOR_SCHEME, DEFAULTS, describeInvalidThemeName, THEME_NAME_PATTERN } from '../ng-add/constants'; import { Schema } from './schema'; import { buildAntiFlashScript } from '../utils/anti-flash-script'; import { parseThemeInputArray } from '../utils/theme-input'; @@ -102,7 +102,7 @@ function extractConfig( const strategy = OPTION_STRATEGY_RE.exec(opts)?.[1] ?? undefined; const themesRaw = OPTION_THEMES_RE.exec(opts)?.[1] ?? ''; - const { themes, schemeMap, invalidSchemes } = parseThemeInputArray(themesRaw); + const { themes, schemeMap, invalidSchemes, invalidNames } = parseThemeInputArray(themesRaw); if (themes.length === 0) themes.push(...DEFAULTS.themes); for (const { name, scheme } of invalidSchemes) { @@ -112,6 +112,14 @@ function extractConfig( ); } + for (const name of invalidNames) { + context.logger.warn( + `⚠ "${name}" is not a valid theme name: ${describeInvalidThemeName(name)}. ` + + `A theme name becomes a CSS class, so it must match ${THEME_NAME_PATTERN.source}. ` + + 'provideThemeStack() will reject it at build time; rename it before continuing.', + ); + } + return { mode, strategy, diff --git a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts index d2f345b..aab6c7d 100644 --- a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts +++ b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts @@ -1,3 +1,5 @@ +import { THEME_NAME_PATTERN } from '../ng-add/constants'; + export interface AntiFlashScriptOptions { storageKey: string; defaultTheme: string; @@ -33,7 +35,12 @@ export function buildAntiFlashScript(options: AntiFlashScriptOptions): string { schemeLookup + `t=localStorage.getItem(k)||d,` + `e=document.documentElement;` + - `if(!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)||v.indexOf(t)===-1)t=d;` + + // Same rule as the library and the schematics. This guard is NOT redundant: + // the value comes from localStorage, which the user (or anything with access + // to the browser) can write, and the declared list it is compared against is + // a plain, hand-editable array in index.html. It runs before Angular and + // writes to classList and to an attribute, so it validates on its own. + `if(!/${THEME_NAME_PATTERN.source}/.test(t)||v.indexOf(t)===-1)t=d;` + `if(t==='system')t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';` + `if(m==='class'||m==='both')e.classList.add(t);` + `if(m==='attribute'||m==='both')e.setAttribute('data-theme',t);` + diff --git a/projects/ngx-theme-stack/schematics/utils/theme-input.ts b/projects/ngx-theme-stack/schematics/utils/theme-input.ts index 28bce68..1b65834 100644 --- a/projects/ngx-theme-stack/schematics/utils/theme-input.ts +++ b/projects/ngx-theme-stack/schematics/utils/theme-input.ts @@ -9,7 +9,7 @@ * parentheses, or quotes). */ -import { parseColorScheme } from '../ng-add/constants'; +import { isValidThemeName, parseColorScheme } from '../ng-add/constants'; /** One parsed theme entry: its name and optional color-scheme hint. */ export interface ParsedThemeInput { @@ -101,15 +101,18 @@ export function parseThemeInputArray(body: string): { themes: string[]; schemeMap: Record; invalidSchemes: InvalidScheme[]; + invalidNames: string[]; } { const themes: string[] = []; const schemeMap: Record = {}; const invalidSchemes: InvalidScheme[] = []; + const invalidNames: string[] = []; for (const entry of splitTopLevelEntries(body)) { const parsed = parseThemeEntry(entry); if (!parsed) continue; themes.push(parsed.name); + if (!isValidThemeName(parsed.name)) invalidNames.push(parsed.name); if (parsed.droppedScheme !== undefined) { invalidSchemes.push({ name: parsed.name, scheme: parsed.droppedScheme }); } else if (parsed.scheme) { @@ -117,5 +120,5 @@ export function parseThemeInputArray(body: string): { } } - return { themes, schemeMap, invalidSchemes }; + return { themes, schemeMap, invalidSchemes, invalidNames }; } diff --git a/projects/ngx-theme-stack/src/lib/config/index.spec.ts b/projects/ngx-theme-stack/src/lib/config/index.spec.ts index f4e67f0..f4dafcf 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.spec.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.spec.ts @@ -102,6 +102,27 @@ describe('provideThemeStack', () => { ).toThrow('Theme cannot be empty or whitespace.'); }); + it('should throw an error if a theme name is not a usable CSS class', () => { + for (const name of ['2lucky', 'lucky theme', 'lucky.theme', 'lucky#theme', 'sueño']) { + expect(() => provideThemeStack({ themes: [name] })).toThrow(NgxThemeStackError); + expect(() => provideThemeStack({ themes: [name] })).toThrow( + /Invalid theme name.*must match/s, + ); + } + }); + + it('should accept names that are valid CSS classes', () => { + for (const name of ['lucky', 'lucky-theme', 'lucky_theme', 'lucky2', '_lucky', '-lucky']) { + expect(() => provideThemeStack({ themes: [name] })).not.toThrow(); + } + }); + + it('should throw for an invalid name in object form too', () => { + expect(() => provideThemeStack({ themes: [{ name: '2lucky', scheme: 'dark' }] })).toThrow( + NgxThemeStackError, + ); + }); + it('should throw an error if defaultTheme is not in the resolved themes list', () => { expect(() => provideThemeStack({ diff --git a/projects/ngx-theme-stack/src/lib/config/index.ts b/projects/ngx-theme-stack/src/lib/config/index.ts index 5b97d78..84400ca 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.ts @@ -3,11 +3,13 @@ import { NgxThemeStackError } from '../errors'; import { DEFAULT_THEMES, DefaultNgTheme, + isValidThemeName, NgConfig, normalizeThemeInputs, NgTheme, NgThemeInput, NgThemeOption, + THEME_NAME_PATTERN, } from '../types'; /** @@ -100,14 +102,24 @@ export const NGX_THEME_STACK_CONFIG = new InjectionToken>( export function provideThemeStack( config: Partial> = {}, ) { - // Validate user-supplied themes: any entry (string or object form) must - // carry a non-empty, trimmed name. + // Validate user-supplied themes. A name has to be a usable CSS class + // identifier: it is emitted as `.name` in class mode, written into + // `classList`, and checked by the anti-flash guard in index.html. Catching it + // here turns what used to be a silent first-paint flash, or a theme that + // never matched its styles, into a build error. if (config.themes) { for (const entry of config.themes as NgThemeInput[]) { const name = typeof entry === 'string' ? entry : entry.name; if (name.trim() === '') { throw new NgxThemeStackError('Theme cannot be empty or whitespace.'); } + if (!isValidThemeName(name)) { + throw new NgxThemeStackError( + `Invalid theme name: "${name}". A theme name must match ${THEME_NAME_PATTERN}. ` + + 'It becomes a CSS class, so it cannot start with a digit, contain ' + + 'whitespace, or contain a CSS metacharacter such as ".", "#" or ">".', + ); + } } } diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts index fefc285..543113e 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { NgxThemeStackError } from '../errors'; -import { NgSystemTheme, NgTheme, NgThemeInput, ResolvedTheme, normalizeThemeInputs } from '../types'; +import { NgSystemTheme, NgTheme, NgThemeInput, ResolvedTheme, THEME_NAME_PATTERN, normalizeThemeInputs } from '../types'; /** * Core service for managing the application's color theme. @@ -250,7 +250,7 @@ export class CoreThemeService { if (!this.#isBrowser || !this.#initialStoredTheme) return; if ( - !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(this.#initialStoredTheme) || + !THEME_NAME_PATTERN.test(this.#initialStoredTheme) || !this.#validThemes.has(this.#initialStoredTheme as NgTheme) ) { this.#antiFlashClass = null; diff --git a/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts index 9dc01f1..238d177 100644 --- a/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts +++ b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts @@ -1,6 +1,9 @@ import { implicitScheme, + isValidThemeName, normalizeThemeInputs, + THEME_NAME_PATTERN, + THEME_NAME_SOURCE, } from './types'; describe('normalizeThemeInputs', () => { @@ -72,6 +75,44 @@ describe('normalizeThemeInputs', () => { }); }); +describe('isValidThemeName', () => { + it('accepts names usable as a CSS class', () => { + for (const name of ['lucky', 'Lucky', 'lucky-theme', 'lucky_theme', 'lucky2', '_lucky', '-lucky']) { + expect(isValidThemeName(name)).toBe(true); + } + }); + + it('rejects a leading digit, which is not a valid CSS class selector', () => { + expect(isValidThemeName('2lucky')).toBe(false); + }); + + it('rejects whitespace, which throws in classList.add', () => { + expect(isValidThemeName('lucky theme')).toBe(false); + expect(isValidThemeName('lucky\ttheme')).toBe(false); + }); + + it('rejects CSS metacharacters, which change what the selector matches', () => { + // .lucky.theme looks for two classes, but the class token is the single + // string 'lucky.theme', so the styles would never match — with no error. + for (const name of ['lucky.theme', 'lucky#id', 'lucky>child', 'lucky[attr]', 'lucky:hover']) { + expect(isValidThemeName(name)).toBe(false); + } + }); + + it('rejects non-ASCII on purpose, as a documented narrower subset', () => { + expect(isValidThemeName('sueño')).toBe(false); + expect(isValidThemeName('año')).toBe(false); + }); + + it('rejects the empty string', () => { + expect(isValidThemeName('')).toBe(false); + }); + + it('exposes the same source the schematics copy', () => { + expect(THEME_NAME_PATTERN.source).toBe(THEME_NAME_SOURCE); + }); +}); + describe('implicitScheme', () => { it('maps light/dark to their matching scheme', () => { expect(implicitScheme('light')).toBe('light'); diff --git a/projects/ngx-theme-stack/src/lib/types.ts b/projects/ngx-theme-stack/src/lib/types.ts index ca3664d..9ad01c4 100644 --- a/projects/ngx-theme-stack/src/lib/types.ts +++ b/projects/ngx-theme-stack/src/lib/types.ts @@ -13,6 +13,15 @@ */ export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; +/** + * Canonical list of accepted `color-scheme` hints. + * + * The type is derived from this runtime array, so the list and the union can + * never disagree. It is copied into the schematics by + * scripts/generate-schematic-constants.mjs. + */ +export const NG_COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; + /** Literal union of built-in themes: `'system' | 'light' | 'dark'`. */ export type DefaultNgTheme = (typeof DEFAULT_THEMES)[number]; @@ -48,7 +57,41 @@ export type NgSystemTheme = Exclude; * It only affects how the browser paints native widgets (scrollbars, inputs, * form controls) in that color scheme — it does not pick a theme for you. */ -export type NgColorScheme = 'light' | 'dark' | 'auto'; +export type NgColorScheme = (typeof NG_COLOR_SCHEMES)[number]; + +/** + * Pattern a theme identifier must match to be usable as a CSS class. + * + * A theme name is emitted as a class selector (`.name`) in class mode, so it has + * to be a valid CSS identifier. The anti-flash script also puts it into + * `classList`, where an ASCII whitespace throws `InvalidCharacterError`. + * + * This is deliberately ASCII-only: a narrower, easy-to-reason-about subset than + * the full CSS identifier grammar, which also admits non-ASCII characters. The + * point is that the rule is **enforced**, so a name that would break is a build + * error instead of a silent first-paint flash. + * + * What each part excludes, and why it matters: + * - a leading digit (`2lucky`) makes `.2lucky` invalid CSS, so the browser drops + * the whole rule and the theme silently loses its styles; + * - an ASCII space (`lucky theme`) throws in `classList.add`; + * - a CSS metacharacter (`.`, `#`, `>`, `[`, `:`, ...) changes what the selector + * means: `.lucky.theme` looks for two classes while the class token is the + * single string `lucky.theme`, so the styles never match, with no error. + * + * ⚠ KEEP IN SYNC with THEME_NAME_PATTERN in + * projects/ngx-theme-stack/schematics/ng-add/constants.ts — the schematics are + * CommonJS and cannot import this ESM module, so the pattern is duplicated and + * a spec asserts both copies are identical. + */ +export const THEME_NAME_SOURCE = '^-?[a-zA-Z_][a-zA-Z0-9_-]*$'; + +export const THEME_NAME_PATTERN = new RegExp(THEME_NAME_SOURCE); + +/** Whether `name` is usable as a theme identifier. See {@link THEME_NAME_PATTERN}. */ +export function isValidThemeName(name: string): boolean { + return THEME_NAME_PATTERN.test(name); +} /** * Declarative theme entry. @@ -64,7 +107,7 @@ export type NgColorScheme = 'light' | 'dark' | 'auto'; * ``` */ export interface NgThemeOption { - /** Theme identifier. Must match `/^[a-zA-Z][a-zA-Z0-9_-]*$/`. */ + /** Theme identifier. Must match {@link THEME_NAME_PATTERN}. */ name: string; /** * Optional color-scheme hint for native UI widgets when this theme is active. diff --git a/projects/ngx-theme-stack/src/schematics/generated-config.spec.ts b/projects/ngx-theme-stack/src/schematics/generated-config.spec.ts new file mode 100644 index 0000000..ee5d440 --- /dev/null +++ b/projects/ngx-theme-stack/src/schematics/generated-config.spec.ts @@ -0,0 +1,101 @@ +import { + DEFAULT_THEMES as LIB_DEFAULT_THEMES, + NG_COLOR_SCHEMES, + THEME_NAME_SOURCE, +} from '../lib/types'; +import { DEFAULT_NG_CONFIG } from '../lib/config'; +import { + COLOR_SCHEMES, + DEFAULT_THEMES, + DEFAULTS, + THEME_NAME_PATTERN, + describeInvalidThemeName, +} from '../../schematics/ng-add/constants'; + +/** + * The schematics compile to CommonJS with `rootDir: "schematics"` and cannot + * import the ESM library, so scripts/generate-schematic-constants.mjs copies the + * shared values into schematics/ng-add/generated-config.ts. + * + * This spec is the mechanism that keeps the copies honest. It fails when the + * generated file is stale — the library changed and the generator was not re-run + * — or when someone edits the generated file by hand. + * + * Regenerate with: pnpm --filter ngx-theme-stack run generate:constants + */ +describe('generated schematic constants stay in sync with the library', () => { + it('copies DEFAULT_THEMES', () => { + expect(DEFAULT_THEMES).toEqual(LIB_DEFAULT_THEMES); + }); + + it('copies the color schemes', () => { + expect(COLOR_SCHEMES).toEqual(NG_COLOR_SCHEMES); + }); + + it('copies the theme name pattern source', () => { + expect(THEME_NAME_PATTERN.source).toBe(THEME_NAME_SOURCE); + }); + + it('copies the default configuration', () => { + expect(DEFAULTS.defaultTheme).toBe(DEFAULT_NG_CONFIG.defaultTheme); + expect(DEFAULTS.storageKey).toBe(DEFAULT_NG_CONFIG.storageKey); + expect(DEFAULTS.mode).toBe(DEFAULT_NG_CONFIG.mode); + expect(DEFAULTS.strategy).toBe(DEFAULT_NG_CONFIG.strategy); + expect(DEFAULTS.themes).toEqual(DEFAULT_NG_CONFIG.themes); + }); + + // Equal `.source` already implies equal behaviour, but asserting the verdicts + // documents what the pattern is FOR, and catches a future change that keeps the + // two copies in step while making them both wrong. + it('agrees with the library on the names it accepts', () => { + const libPattern = new RegExp(THEME_NAME_SOURCE); + const cases = [ + 'lucky', + 'lucky-theme', + 'lucky_theme', + 'lucky2', + 'Lucky', + '_lucky', + '-lucky', + '', + '2lucky', + 'lucky theme', + 'lucky.theme', + 'lucky#theme', + 'lucky>theme', + 'sueño', + 'año', + ]; + for (const name of cases) { + expect(THEME_NAME_PATTERN.test(name)).toBe(libPattern.test(name)); + } + }); +}); + +describe('describeInvalidThemeName', () => { + it('explains an empty name', () => { + expect(describeInvalidThemeName(' ')).toMatch(/empty/); + }); + + it('explains a leading digit', () => { + expect(describeInvalidThemeName('2lucky')).toMatch(/starts with a digit/); + }); + + it('explains whitespace', () => { + expect(describeInvalidThemeName('lucky theme')).toMatch(/whitespace/); + expect(describeInvalidThemeName('lucky\ttheme')).toMatch(/whitespace/); + }); + + it('explains a CSS metacharacter', () => { + expect(describeInvalidThemeName('lucky.theme')).toMatch(/metacharacter/); + expect(describeInvalidThemeName('lucky#theme')).toMatch(/metacharacter/); + expect(describeInvalidThemeName('lucky:hover')).toMatch(/metacharacter/); + }); + + it('falls back to the allowed-set explanation', () => { + expect(describeInvalidThemeName('sueño')).toMatch(/outside the allowed set/); + // Non-breaking space is not ASCII whitespace, so it does not throw in + // classList; the accurate reason is that it is outside the allowed set. + expect(describeInvalidThemeName('lucky\u00a0theme')).toMatch(/outside the allowed set/); + }); +}); diff --git a/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts index 02cea94..3eb16a6 100644 --- a/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts +++ b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts @@ -100,6 +100,7 @@ describe('parseThemeInputArray', () => { themes: ['system', 'light', 'dark', 'sunset', 'sepia'], schemeMap: { sunset: 'dark', sepia: 'light' }, invalidSchemes: [], + invalidNames: [], }); }); @@ -108,6 +109,7 @@ describe('parseThemeInputArray', () => { themes: ['system', 'light', 'dark'], schemeMap: {}, invalidSchemes: [], + invalidNames: [], }); }); @@ -125,6 +127,7 @@ describe('parseThemeInputArray', () => { themes: ['light', 'dark', 'sepia'], schemeMap: { sepia: 'light' }, invalidSchemes: [], + invalidNames: [], }); }); @@ -136,6 +139,7 @@ describe('parseThemeInputArray', () => { themes: ['light', 'lucky', 'sunset'], schemeMap: { sunset: 'dark' }, invalidSchemes: [{ name: 'lucky', scheme: 'banana' }], + invalidNames: [], }); }); @@ -146,6 +150,7 @@ describe('parseThemeInputArray', () => { themes: ['light', 'paper'], schemeMap: {}, invalidSchemes: [{ name: 'paper', scheme: 'none' }], + invalidNames: [], }); }); @@ -156,6 +161,18 @@ describe('parseThemeInputArray', () => { themes: ['light', 'paper'], schemeMap: { paper: 'auto' }, invalidSchemes: [], + invalidNames: [], + }); + }); + + it('reports names that are not usable as a CSS class, without dropping them', () => { + const body = "'light', '2lucky', 'lucky theme', 'lucky.theme', 'sueño'"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', '2lucky', 'lucky theme', 'lucky.theme', 'sueño'], + schemeMap: {}, + invalidSchemes: [], + invalidNames: ['2lucky', 'lucky theme', 'lucky.theme', 'sueño'], }); }); }); diff --git a/scripts/generate-schematic-constants.mjs b/scripts/generate-schematic-constants.mjs new file mode 100755 index 0000000..5635bc0 --- /dev/null +++ b/scripts/generate-schematic-constants.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * Generates the schematic copy of the values that the library owns. + * + * Why this exists + * --------------- + * The schematics compile to CommonJS with `rootDir: "schematics"`, while the + * library is built by ng-packagr from `src/` as ESM. A single source file cannot + * belong to both, and TypeScript enforces it: + * + * error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics' + * + * So the shared values used to be copy-pasted with a "KEEP IN SYNC" comment, + * which is a promise rather than a mechanism. This script is the mechanism: the + * library is the source of truth, and the schematic copy is generated, so it + * cannot drift. `src/schematics/generated-config.spec.ts` fails if it somehow + * does (for example if someone edits the generated file by hand or forgets to + * regenerate after changing the library). + * + * Values are read through the TypeScript compiler API rather than with regular + * expressions, so the extraction does not break on formatting, comments, or + * trailing commas. + * + * Run it directly, or let the `prebuild` hook of projects/ngx-theme-stack run it. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const libRoot = resolve(repoRoot, 'projects/ngx-theme-stack'); +const TYPES = resolve(libRoot, 'src/lib/types.ts'); +const CONFIG = resolve(libRoot, 'src/lib/config/index.ts'); +const OUTPUT = resolve(libRoot, 'schematics/ng-add/generated-config.ts'); + +// ── Extraction helpers over the TypeScript AST ──────────────────────────────── + +function parse(path) { + return ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); +} + +/** Finds `export const NAME = ` and returns the initializer node. */ +function findInitializer(sourceFile, name) { + let found; + const visit = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === name && + node.initializer + ) { + found = node.initializer; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!found) { + throw new Error(`could not find "export const ${name}" in ${sourceFile.fileName}`); + } + return found; +} + +function expectString(node, name) { + if (!ts.isStringLiteral(node)) { + throw new Error(`"${name}" must be a plain string literal to be generated`); + } + return node.text; +} + +function expectStringArray(node, name) { + if (!ts.isAsExpression(node) && !ts.isArrayLiteralExpression(node)) { + throw new Error(`"${name}" must be a string array literal to be generated`); + } + const array = ts.isAsExpression(node) ? node.expression : node; + if (!ts.isArrayLiteralExpression(array)) { + throw new Error(`"${name}" must be an array literal to be generated`); + } + return array.elements.map((element, index) => { + if (!ts.isStringLiteral(element)) { + throw new Error(`"${name}[${index}]" must be a string literal to be generated`); + } + return element.text; + }); +} + +function expectStringProperties(node, name, properties) { + if (!ts.isObjectLiteralExpression(node)) { + throw new Error(`"${name}" must be an object literal to be generated`); + } + const out = {}; + for (const property of properties) { + const found = node.properties.find( + (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === property, + ); + if (!found) throw new Error(`"${name}.${property}" not found`); + out[property] = expectString(found.initializer, `${name}.${property}`); + } + return out; +} + +const quote = (value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +const asConstArray = (values) => `[${values.map(quote).join(', ')}] as const`; + +// ── Read the canonical values ──────────────────────────────────────────────── + +const types = parse(TYPES); +const config = parse(CONFIG); + +const defaultThemes = expectStringArray(findInitializer(types, 'DEFAULT_THEMES'), 'DEFAULT_THEMES'); +const colorSchemes = expectStringArray( + findInitializer(types, 'NG_COLOR_SCHEMES'), + 'NG_COLOR_SCHEMES', +); +const themeNameSource = expectString(findInitializer(types, 'THEME_NAME_SOURCE'), 'THEME_NAME_SOURCE'); +const defaults = expectStringProperties(findInitializer(config, 'DEFAULT_NG_CONFIG'), 'DEFAULT_NG_CONFIG', [ + 'defaultTheme', + 'storageKey', + 'mode', + 'strategy', +]); + +// ── Emit ───────────────────────────────────────────────────────────────────── + +const contents = `/** + * AUTO-GENERATED by scripts/generate-schematic-constants.mjs — DO NOT EDIT. + * + * Source of truth: + * - src/lib/types.ts (DEFAULT_THEMES, NG_COLOR_SCHEMES, THEME_NAME_SOURCE) + * - src/lib/config/index.ts (DEFAULT_NG_CONFIG) + * + * The schematics compile to CommonJS with \`rootDir: "schematics"\` and cannot + * import the ESM library, so the build copies these values here. Regenerate with + * \`pnpm --filter ngx-theme-stack run generate:constants\` (also runs on prebuild). + * + * src/schematics/generated-config.spec.ts asserts these still match the library. + */ + +export const DEFAULT_THEMES = ${asConstArray(defaultThemes)}; + +export const NG_COLOR_SCHEMES = ${asConstArray(colorSchemes)}; + +export const THEME_NAME_SOURCE = ${quote(themeNameSource)}; + +export const DEFAULT_NG_CONFIG = { + defaultTheme: ${quote(defaults['defaultTheme'])}, + storageKey: ${quote(defaults['storageKey'])}, + mode: ${quote(defaults['mode'])}, + strategy: ${quote(defaults['strategy'])}, +} as const; +`; + +let previous = ''; +try { + previous = readFileSync(OUTPUT, 'utf8'); +} catch { + // first run +} + +if (previous === contents) { + console.log(`unchanged ${relative(repoRoot, OUTPUT)}`); +} else { + writeFileSync(OUTPUT, contents); + console.log(`${previous ? 'updated ' : 'created '}${relative(repoRoot, OUTPUT)}`); +} From 8f39ef5a172b491c6dc09e5277be2f8a7badc5d7 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 22:17:52 -0500 Subject: [PATCH 13/15] fix(lib): whitelist the runtime color scheme, and make the yes/no prompt strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loose ends from the theme-name work. 1. An unknown scheme could still reach the DOM. normalizeThemeInputs passed entry.scheme through untouched, so a hand-written config — which reaches the runtime without the compiler having checked it — could put an arbitrary string into style.setProperty('color-scheme', ...). Fixed on both sides: - normalizeThemeInputs replaces a scheme outside NG_COLOR_SCHEMES with 'auto', so resolvedThemes is authoritative; - applyColorSchemeHint now uses a positive whitelist. Only 'light' and 'dark' are written; everything else removes the hint, which is what 'auto' means. The check is no longer "scheme === auto", so a value that bypassed normalization clears the hint instead of being written. 2. The agent-skill prompt read "Generate an AI Agent Skill? [Y/n]" with "input !== 'n'", so any typo (asdf) counted as yes. New parseYesNo() accepts y/yes/n/no case-insensitively, takes the prompt default on an empty answer, and returns undefined for anything else so askYesNo() can re-ask instead of guessing. The helper is a pure function, so it is unit tested. Verified: 147 tests (was 138), tsc clean for the three configs, lint clean, build:lib and build:demo OK, publish guards 5/5. --- .../schematics/ng-add/index.ts | 8 ++-- .../schematics/ng-add/utils.ts | 37 +++++++++++++++++++ .../src/lib/core/core-theme.service.spec.ts | 21 +++++++++++ .../src/lib/core/core-theme.service.ts | 16 ++++---- .../src/lib/types.theme-scheme.spec.ts | 35 ++++++++++++++++++ projects/ngx-theme-stack/src/lib/types.ts | 18 ++++++++- .../src/schematics/ng-add-utils.spec.ts | 28 ++++++++++++++ 7 files changed, 151 insertions(+), 12 deletions(-) diff --git a/projects/ngx-theme-stack/schematics/ng-add/index.ts b/projects/ngx-theme-stack/schematics/ng-add/index.ts index 73e16ee..a59932e 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/index.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/index.ts @@ -11,7 +11,7 @@ import { THEME_NAME_PATTERN, } from './constants'; import { Schema } from './schema'; -import { ask, askList, assertAngularProject, buildProvideCall, createRl, detectPackageManager } from './utils'; +import { ask, askList, askYesNo, assertAngularProject, buildProvideCall, createRl, detectPackageManager } from './utils'; import { generateSkill } from '../skill/index'; interface SchematicConfig { @@ -85,8 +85,10 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise { return new Promise((resolve) => rl.question(question, (a) => resolve(a.trim()))); } +/** + * Parses a yes/no answer. + * + * Returns `undefined` for anything unrecognised, so callers can re-ask instead of + * guessing. Previously the agent-skill prompt used `input !== 'n'`, which treated + * a typo like `asdf` as "yes". + * + * An empty answer takes the prompt's default, which is what the `[Y/n]` or + * `[y/N]` suffix communicates. + */ +export function parseYesNo(input: string, defaultYes: boolean): boolean | undefined { + const value = input.trim().toLowerCase(); + if (value === '') return defaultYes; + if (value === 'y' || value === 'yes') return true; + if (value === 'n' || value === 'no') return false; + return undefined; +} + +/** + * Asks a yes/no question, re-asking until the answer is recognised. + * + * @param question Prompt text. Include the default, e.g. `"... [Y/n]: "`. + * @param defaultYes Whether an empty answer means yes. Defaults to `true`. + */ +export async function askYesNo( + rl: readline.Interface, + question: string, + defaultYes = true, +): Promise { + for (;;) { + const answer = await ask(rl, question); + const parsed = parseYesNo(answer, defaultYes); + if (parsed !== undefined) return parsed; + process.stdout.write(` \u26a0 Please answer "y" or "n".\n`); + } +} + /** * Displays a numbered list of items to the user and returns the selected item. * If the input is invalid or ignored, the default index item is returned. diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts index 7b676c8..d520656 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts @@ -283,6 +283,27 @@ describe('CoreThemeService', () => { expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); }); + it('never writes a scheme that is not light/dark onto the root element', () => { + // A hand-written config can bypass provideThemeStack() and the compiler, so + // resolvedThemes may carry a value the types would have rejected. An unknown + // scheme must clear the hint rather than reach setProperty('color-scheme'). + const { service } = setup({ + themes: ['dark2', 'ghost'], + resolvedThemes: [ + { name: 'dark2', scheme: 'dark' }, + { name: 'ghost', scheme: 'banana' }, + ], + } as unknown as Partial); + + service.setTheme('dark2'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + + service.setTheme('ghost'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); + }); + // ── Hydration Signal ── it('should set isHydrated to true in browser environment after next render', () => { diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts index 543113e..fb90b22 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts @@ -235,15 +235,17 @@ export class CoreThemeService { } private applyColorSchemeHint(host: HTMLElement, theme: NgTheme): void { - // 'system' never reaches this path — it is resolved to 'dark' or 'light' - // by `resolvedTheme`. Custom themes carry the user-declared scheme hint - // (default: 'auto', no change). - const scheme = this.#schemeByName.get(theme) ?? 'auto'; - if (scheme === 'auto') { - host.style.removeProperty('color-scheme'); + // A positive whitelist, not a comparison against 'auto': only the two values + // that mean something are written. Anything else — including a hand-written + // config whose `resolvedThemes` bypassed normalizedThemeInputs and the + // compiler — falls through to removing the hint, which is what 'auto' means. + // Without this, an arbitrary string would reach setProperty('color-scheme'). + const scheme = this.#schemeByName.get(theme); + if (scheme === 'light' || scheme === 'dark') { + host.style.setProperty('color-scheme', scheme); return; } - host.style.setProperty('color-scheme', scheme); + host.style.removeProperty('color-scheme'); } private captureAntiFlashClass(): void { diff --git a/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts index 238d177..9f35944 100644 --- a/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts +++ b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts @@ -1,6 +1,8 @@ import { implicitScheme, + isNgColorScheme, isValidThemeName, + NG_COLOR_SCHEMES, normalizeThemeInputs, THEME_NAME_PATTERN, THEME_NAME_SOURCE, @@ -41,6 +43,25 @@ describe('normalizeThemeInputs', () => { expect(result).toEqual([{ name: 'forest', scheme: 'auto' }]); }); + it('replaces an unknown scheme with auto instead of passing it through', () => { + // A hand-written config can reach the runtime without the compiler having + // checked it; the value must not travel to setProperty('color-scheme', ...). + const result = normalizeThemeInputs([ + { name: 'ghost', scheme: 'banana' } as never, + { name: 'paper', scheme: 'none' } as never, + ]); + expect(result).toEqual([ + { name: 'ghost', scheme: 'auto' }, + { name: 'paper', scheme: 'auto' }, + ]); + }); + + it('keeps every accepted scheme', () => { + for (const scheme of NG_COLOR_SCHEMES) { + expect(normalizeThemeInputs([{ name: 'x', scheme }])).toEqual([{ name: 'x', scheme }]); + } + }); + it('ignores empty-string entries', () => { const result = normalizeThemeInputs(['', ' ', 'dark']); expect(result).toEqual([{ name: 'dark', scheme: 'dark' }]); @@ -113,6 +134,20 @@ describe('isValidThemeName', () => { }); }); +describe('isNgColorScheme', () => { + it('accepts every declared scheme', () => { + for (const scheme of NG_COLOR_SCHEMES) { + expect(isNgColorScheme(scheme)).toBe(true); + } + }); + + it('rejects anything else, including non-strings', () => { + for (const value of ['banana', 'Light', 'none', '', undefined, null, 0, {}]) { + expect(isNgColorScheme(value)).toBe(false); + } + }); +}); + describe('implicitScheme', () => { it('maps light/dark to their matching scheme', () => { expect(implicitScheme('light')).toBe('light'); diff --git a/projects/ngx-theme-stack/src/lib/types.ts b/projects/ngx-theme-stack/src/lib/types.ts index 9ad01c4..9d618cd 100644 --- a/projects/ngx-theme-stack/src/lib/types.ts +++ b/projects/ngx-theme-stack/src/lib/types.ts @@ -22,6 +22,16 @@ export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; */ export const NG_COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; +/** + * Whether `value` is one of the accepted color schemes. + * + * Used to validate a hand-written config, which can reach the runtime without + * the compiler having checked it. + */ +export function isNgColorScheme(value: unknown): value is NgColorScheme { + return (NG_COLOR_SCHEMES as readonly unknown[]).includes(value); +} + /** Literal union of built-in themes: `'system' | 'light' | 'dark'`. */ export type DefaultNgTheme = (typeof DEFAULT_THEMES)[number]; @@ -140,7 +150,11 @@ export interface ResolvedTheme { * Normalizes a `themes` input (string or object form) into `ResolvedTheme[]`. * * - String entries are treated as scheme-less and default to `'auto'`. - * - Object entries must have a non-empty `name` and a valid `scheme`. + * - Object entries must have a non-empty `name`. A `scheme` outside + * {@link NG_COLOR_SCHEMES} is replaced by `'auto'` rather than passed through: + * a hand-written config can reach the runtime without the compiler having + * checked it, and `'banana'` would otherwise be written straight into + * `style.setProperty('color-scheme', ...)`. * - Built-in themes `'system'`, `'light'`, `'dark'` are special-cased: * `'light'` and `'dark'` carry an implicit scheme of their own name; * `'system'` is scheme-less (resolved via `matchMedia` at runtime). @@ -160,7 +174,7 @@ export function normalizeThemeInputs(themes: readonly NgThemeInput[]): ResolvedT if (name === '') continue; if (seen.has(name)) continue; seen.add(name); - out.push({ name, scheme: entry.scheme ?? 'auto' }); + out.push({ name, scheme: isNgColorScheme(entry.scheme) ? entry.scheme : 'auto' }); } } return out; diff --git a/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts b/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts index 7eec0ad..585de1b 100644 --- a/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts +++ b/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts @@ -4,8 +4,36 @@ import { buildProvideCall, detectPackageManager, looksLikeNestMain, + parseYesNo, } from '../../schematics/ng-add/utils'; +describe('parseYesNo', () => { + it('accepts y and yes, in any case', () => { + for (const answer of ['y', 'Y', 'yes', 'YES', 'Yes', ' y ']) { + expect(parseYesNo(answer, false)).toBe(true); + } + }); + + it('accepts n and no, in any case', () => { + for (const answer of ['n', 'N', 'no', 'NO', 'No', ' n ']) { + expect(parseYesNo(answer, true)).toBe(false); + } + }); + + it('takes the prompt default on an empty answer', () => { + expect(parseYesNo('', true)).toBe(true); + expect(parseYesNo(' ', true)).toBe(true); + expect(parseYesNo('', false)).toBe(false); + }); + + it('returns undefined for anything unrecognised, instead of guessing', () => { + // The agent-skill prompt used `input !== 'n'`, so a typo counted as "yes". + for (const answer of ['asdf', 'sí', 'si', 'yep', 'maybe', '1', '0', 'nope']) { + expect(parseYesNo(answer, true)).toBeUndefined(); + } + }); +}); + describe('looksLikeNestMain', () => { it('detects a NestJS import', () => { expect(looksLikeNestMain("import { NestFactory } from '@nestjs/core';")).toBe(true); From 04f1569d46a3896b1dfc0086de0417372df90f73 Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 22:44:51 -0500 Subject: [PATCH 14/15] docs(skill): document what the runtime now enforces, and bump the skill version The generated skill was mechanically in sync with its template (regenerating produced no diff), but the template itself had fallen behind the code: - the API reference did not mention the theme-name rule, while provideThemeStack has thrown NgxThemeStackError for a bad name since 5c9044e. A consumer reading the skill would be surprised by a build error the docs never warned about; - the sync entry did not say that it now reports an invalid name or scheme instead of silently rewriting it; - metadata.version was still 1.2.2 after adding the object-form themes, the scheme table and the themeNames/resolvedThemes fields, which made the field meaningless. Bumped to 1.3.0. Verified: tsc clean, 147 tests, lint clean, build:lib and build:demo OK, publish guards 5/5, regenerated output has no literal escapes and still contains no 'none' scheme. --- .agents/skills/ngx-theme-stack/SKILL.md | 3 ++- .../references/api-reference.md | 18 ++++++++++++++++ .../ngx-theme-stack/schematics/skill/index.ts | 21 ++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.agents/skills/ngx-theme-stack/SKILL.md b/.agents/skills/ngx-theme-stack/SKILL.md index e505255..490db3c 100644 --- a/.agents/skills/ngx-theme-stack/SKILL.md +++ b/.agents/skills/ngx-theme-stack/SKILL.md @@ -4,7 +4,7 @@ description: Signal-based theme manager for Angular 20+. Use this skill to confi compatibility: Angular 20+ with TypeScript. Optional Tailwind CSS v4. metadata: author: WanderleeDev - version: '1.2.2' + version: '1.3.0' --- # ngx-theme-stack @@ -30,6 +30,7 @@ Headless, signal-based theme manager for Angular 20+. - **Manual execution**: Run `pnpm run ngx-theme-stack:sync` (or `npm run ngx-theme-stack:sync` / `yarn run ngx-theme-stack:sync`). - **Auto-Sync**: Runs automatically before serving or building via `"prestart"` and `"prebuild"` hooks in `package.json`. - **When to sync**: Run after adding/removing themes, renaming themes, changing configuration settings (storageKey, mode, strategy), or manually editing index.html. + - **What it reports**: an invalid theme name, or a `scheme` outside `'light' | 'dark' | 'auto'`, is dropped from the regenerated config and reported as a warning. It never rewrites silently, and it never emits a value the compiler would reject. - **Debugging**: If a theme reverts to default/system on reload, check if the theme identifier is missing in the valid themes array (`v`) in `index.html`. If missing, run synchronization. - `isDark()` / `isLight()` return false for custom themes (use `resolvedTheme()`). - `selectedTheme()` can be `'system'`; `resolvedTheme()` is always the concrete theme applied to the DOM (never `'system'`). diff --git a/.agents/skills/ngx-theme-stack/references/api-reference.md b/.agents/skills/ngx-theme-stack/references/api-reference.md index a76c85a..4e69c8d 100644 --- a/.agents/skills/ngx-theme-stack/references/api-reference.md +++ b/.agents/skills/ngx-theme-stack/references/api-reference.md @@ -55,8 +55,26 @@ provideThemeStack({ themes: ['sepia'] as const }) provideThemeStack({ themes: [{ name: 'sepia', scheme: 'light' }] as const }) ``` +### Theme names + +A theme name becomes a CSS class (`.name`) and is written into `classList`, so it +must be usable as a CSS identifier. It has to match: + +``` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +| Name | Accepted | Why | +| --- | --- | --- | +| `lucky`, `lucky-theme`, `lucky_theme`, `lucky2`, `_lucky`, `-lucky` | yes | usable as-is | +| `2lucky` | no | `.2lucky` is invalid CSS, so the browser drops the rule and the theme silently loses its styles | +| `lucky theme` | no | `classList.add` throws `InvalidCharacterError` on ASCII whitespace | +| `lucky.theme` | no | `.lucky.theme` matches two classes while the class token is the single string `lucky.theme`, so the styles never apply, with no error | +| `sueño`, `café` | no | deliberately ASCII-only, a narrower subset than CSS allows | + **Throws `NgxThemeStackError` when:** - A theme entry is empty, or `defaultTheme` is not in themes, or `storageKey` is empty. +- **A theme name does not match the pattern above.** This is a build error on purpose: the failure it prevents (a first-paint flash, or a theme whose styles never match) used to be silent. - `setTheme()` is called with a theme not in the configured themes list. > After changing `themes` (adding, removing, renaming, or adding a `scheme`), diff --git a/projects/ngx-theme-stack/schematics/skill/index.ts b/projects/ngx-theme-stack/schematics/skill/index.ts index f5be54f..9a03d30 100644 --- a/projects/ngx-theme-stack/schematics/skill/index.ts +++ b/projects/ngx-theme-stack/schematics/skill/index.ts @@ -11,7 +11,7 @@ description: Signal-based theme manager for Angular 20+. Use this skill to confi compatibility: Angular 20+ with TypeScript. Optional Tailwind CSS v4. metadata: author: WanderleeDev - version: '1.2.2' + version: '1.3.0' --- # ngx-theme-stack @@ -37,6 +37,7 @@ Headless, signal-based theme manager for Angular 20+. - **Manual execution**: Run \`pnpm run ngx-theme-stack:sync\` (or \`npm run ngx-theme-stack:sync\` / \`yarn run ngx-theme-stack:sync\`). - **Auto-Sync**: Runs automatically before serving or building via \`"prestart"\` and \`"prebuild"\` hooks in \`package.json\`. - **When to sync**: Run after adding/removing themes, renaming themes, changing configuration settings (storageKey, mode, strategy), or manually editing index.html. + - **What it reports**: an invalid theme name, or a \`scheme\` outside \`'light' | 'dark' | 'auto'\`, is dropped from the regenerated config and reported as a warning. It never rewrites silently, and it never emits a value the compiler would reject. - **Debugging**: If a theme reverts to default/system on reload, check if the theme identifier is missing in the valid themes array (\`v\`) in \`index.html\`. If missing, run synchronization. - \`isDark()\` / \`isLight()\` return false for custom themes (use \`resolvedTheme()\`). - \`selectedTheme()\` can be \`'system'\`; \`resolvedTheme()\` is always the concrete theme applied to the DOM (never \`'system'\`). @@ -231,8 +232,26 @@ provideThemeStack({ themes: ['sepia'] as const }) provideThemeStack({ themes: [{ name: 'sepia', scheme: 'light' }] as const }) \`\`\` +### Theme names + +A theme name becomes a CSS class (\`.name\`) and is written into \`classList\`, so it +must be usable as a CSS identifier. It has to match: + +\`\`\` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +\`\`\` + +| Name | Accepted | Why | +| --- | --- | --- | +| \`lucky\`, \`lucky-theme\`, \`lucky_theme\`, \`lucky2\`, \`_lucky\`, \`-lucky\` | yes | usable as-is | +| \`2lucky\` | no | \`.2lucky\` is invalid CSS, so the browser drops the rule and the theme silently loses its styles | +| \`lucky theme\` | no | \`classList.add\` throws \`InvalidCharacterError\` on ASCII whitespace | +| \`lucky.theme\` | no | \`.lucky.theme\` matches two classes while the class token is the single string \`lucky.theme\`, so the styles never apply, with no error | +| \`sueño\`, \`café\` | no | deliberately ASCII-only, a narrower subset than CSS allows | + **Throws \`NgxThemeStackError\` when:** - A theme entry is empty, or \`defaultTheme\` is not in themes, or \`storageKey\` is empty. +- **A theme name does not match the pattern above.** This is a build error on purpose: the failure it prevents (a first-paint flash, or a theme whose styles never match) used to be silent. - \`setTheme()\` is called with a theme not in the configured themes list. > After changing \`themes\` (adding, removing, renaming, or adding a \`scheme\`), From 677c32ecb9e6cf8c69d854aca7c339ffa5da574b Mon Sep 17 00:00:00 2001 From: wanderlee Date: Mon, 5 Oct 2026 22:47:57 -0500 Subject: [PATCH 15/15] chore: release v3.10.0-next.1 --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- projects/ngx-theme-stack/package.json | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6572ce..91aa497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +# [3.10.0-next.1](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.10.0-next.0...v3.10.0-next.1) (2026-10-06) + + +### Bug Fixes + +* **lib:** whitelist the runtime color scheme, and make the yes/no prompt strict ([8f39ef5](https://github.com/WanderleeDev/ngx-theme-stack/commit/8f39ef5a172b491c6dc09e5277be2f8a7badc5d7)) +* **schematics:** validate the color-scheme prompt and drop the duplicate none ([9c83a9c](https://github.com/WanderleeDev/ngx-theme-stack/commit/9c83a9c15ab5ff0909b4938ab836bea8c12fa1aa)) + + +### Features + +* **lib:** enforce theme names as CSS identifiers, and generate the schematic copies ([5c9044e](https://github.com/WanderleeDev/ngx-theme-stack/commit/5c9044e678de0d169c497932cbab22643274a5c1)) + # [3.10.0-next.0](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.9.4...v3.10.0-next.0) (2026-10-05) diff --git a/package.json b/package.json index 5a393da..ecce39c 100644 --- a/package.json +++ b/package.json @@ -74,5 +74,5 @@ "typescript-eslint": "8.62.1", "vitest": "^4.1.11" }, - "version": "3.10.0-next.0" + "version": "3.10.0-next.1" } diff --git a/projects/ngx-theme-stack/package.json b/projects/ngx-theme-stack/package.json index 753f5b7..843ac99 100644 --- a/projects/ngx-theme-stack/package.json +++ b/projects/ngx-theme-stack/package.json @@ -1,6 +1,6 @@ { "name": "ngx-theme-stack", - "version": "3.10.0-next.0", + "version": "3.10.0-next.1", "description": "A stack of themes for Angular applications.", "keywords": [ "angular",