Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .agents/skills/ngx-theme-stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,16 +18,19 @@ 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). 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`.
- **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'`).
Expand Down
66 changes: 66 additions & 0 deletions .agents/skills/ngx-theme-stack/references/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,74 @@ 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. |

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
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 })
```

### 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`),
> 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
Expand All @@ -33,6 +97,8 @@ Foundation service managing state (signals), persistence, system preference, and
| `isDark()` / `isLight()` | `Signal<boolean>` | `true` for dark/light (returns `false` for custom themes). |
| `isSystem()` / `isHydrated()` | `Signal<boolean>` | 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. |

---
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>` 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
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# 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)


### 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)


Expand Down
20 changes: 20 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
81 changes: 81 additions & 0 deletions odd/tasks/color-scheme-validation.md
Original file line number Diff line number Diff line change
@@ -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
`<div class="lucky theme">` (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', ...)`.
78 changes: 78 additions & 0 deletions odd/tasks/release-hardening.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading