css-is-awesome 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -168,7 +168,7 @@ Why it matters: components call `cia.space(4)`, which resolves to `var(--space-4
168
168
 
169
169
  Two `<link media>` themes still work under the new selector model: a stylesheet whose `media` doesn't match is loaded but never applied, so only the matching file's `:root` block lands.
170
170
 
171
- Validator: `node scripts/theme-validator.js path/to/theme.css` (or `--all` for every shipped theme). Every theme must declare every required contract token (127 required in contract 1.1; missing required tokens always fail, missing optional ones are reported as info). The audit also runs a WCAG 2.2 AA contrast check over 22 pairs; **a11y FAILs are fatal by default** as of v0.7. Pass `--allow-a11y-fail` to downgrade contrast failures to a report-only warning (the older `--strict` flag is accepted as a no-op alias). `--border-default` is treated as decorative per WCAG 2.2 SC 1.4.11 and reports as info, not FAIL.
171
+ Validator: `node scripts/theme-validator.js path/to/theme.css` (or `--all` for every shipped theme). Every theme must declare every required contract token (127 required in contract 1.3; missing required tokens always fail, missing optional ones are reported as info). The audit also runs a WCAG 2.2 AA contrast check over 22 pairs; **a11y FAILs are fatal by default** as of v0.7. Pass `--allow-a11y-fail` to downgrade contrast failures to a report-only warning (the older `--strict` flag is accepted as a no-op alias). `--border-default` is treated as decorative per WCAG 2.2 SC 1.4.11 and reports as info, not FAIL.
172
172
 
173
173
  ### Theme init (Next.js / SSR consumers)
174
174
 
@@ -338,7 +338,7 @@ Or run this in-repo copy directly — needs its SDK peer deps installed manually
338
338
  }
339
339
  ```
340
340
 
341
- Either way it exposes **33 tools** across 8 families:
341
+ Either way it exposes **34 tools** across 8 families:
342
342
 
343
343
  - **Themes** — `list_themes`, `get_theme`, `search_themes`
344
344
  - **Mixins** — `list_mixins`, `get_mixin`, `search_mixins` (real signatures — don't guess)
@@ -347,6 +347,7 @@ Either way it exposes **33 tools** across 8 families:
347
347
  - **Animations** — `list_animations`, `get_animation`
348
348
  - **Components** — `list_components`, `get_component`, `search_components`
349
349
  - **Recipes** — `list_recipes`, `get_recipe`
350
+ - **Theme upgrades** — `fix_theme` (rewrite deprecated tokens to their replacements; returns text, never writes)
350
351
  - **Doc readers** — `read_llm_txt`, `read_changelog`, `read_migration`, `read_theming`, `read_agents`, `read_contract`, `read_three_tiers`, `read_readme`, `read_versioning`
351
352
  - **Helpers** — `assemble_prompt` (bundle context), `resolve_size` (snap a design px value to cia's 4px grid — call this whenever a design tool hands you a raw px value), `validate_theme` (run the real theme validator — contract + contrast — on a CSS string before you ship it), `theme_from_tokens` (DTCG / Tokens Studio / flat token JSON → a complete theme.css, base-inherited and validated; same function as `cia theme from-tokens`), `get_token_map` (that path → token mapping as data, or how one path resolves — read it instead of re-deriving the rules)
352
353
 
@@ -366,7 +367,12 @@ Either way it exposes **33 tools** across 8 families:
366
367
  default boilerplate), unmapped paths pass through verbatim and are reported, a `--dark` file or paired
367
368
  `color-light`/`color-dark` groups become `light-dark()`, and the validator + WCAG audit run before anything is
368
369
  written. Same function as the MCP `theme_from_tokens` tool; `npx cia theme map [--json] [--path <token.path>]` prints the design-token → cia-token mapping that verb applies (same data as the MCP `get_token_map` tool), for anyone who needs to map one token name the way the converter would. Run any verb with `--help`. (`cia init` remains
369
- planned.)
370
+ planned.) `npx cia fix-theme <theme.css> [--write]` moves a theme onto
371
+ current token names: it renames any DEPRECATED token to its replacement,
372
+ changing the property only — values, comments and ordering survive, so the
373
+ rendered theme is identical. Prints by default; writes only with `--write`;
374
+ a block already declaring the replacement is reported, never merged. Same
375
+ function as the MCP `fix_theme` tool.
370
376
  - **JSON token export** — Tokens Studio-format sample in `figma-tokens/tokens.json`; it round-trips through `cia theme from-tokens` (paired light/dark groups → one `light-dark()` theme).
371
377
  - **`llm.txt`** — at the repo root and served from the docs site; single-fetch
372
378
  summary for any AI agent. Also readable over MCP via `read_llm_txt`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ # [1.21.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.20.0...v1.21.0) (2026-09-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** cia fix-theme — rename deprecated tokens to their replacements ([665d871](https://github.com/Jerry2d3d/css-is-awesome/commit/665d87129f578d1ac38f410ce7461f37bbe24357))
9
+
3
10
  # [1.20.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.19.2...v1.20.0) (2026-09-23)
4
11
 
5
12
 
package/CONTRACT.md CHANGED
@@ -635,3 +635,10 @@ A PR that adds a new contract glyph must:
635
635
  Per-theme override glyphs are NEVER required by the contract — themes
636
636
  opt in glyph-by-glyph by declaring `--cia-icon-<name>` and shipping the
637
637
  replacement file alongside.
638
+ >
639
+ > **Deprecations are machine-readable.** Every one lives in the `deprecated` map in
640
+ > [`scripts/theme-contract.json`](./scripts/theme-contract.json) with the token it replaces,
641
+ > when it was deprecated and when it goes. The validator prints it, `get_token` returns it,
642
+ > `npx cia analyze` flags it in your stylesheets, and `npx cia fix-theme <file> --write`
643
+ > renames it for you — property only, so the theme renders identically. `check:contract`
644
+ > fails the build if a deprecation points at a token that does not exist.
package/MIGRATION.md CHANGED
@@ -30,6 +30,12 @@ so the guarantee is enforced rather than promised.
30
30
  | `--background-hero` | optional, read by nothing | **deprecated**; `--page-hero-bg` falls back through it, so an existing declaration keeps working until contract 2 |
31
31
  | Your custom theme | validates | validates — the new tokens are optional, and missing optional tokens report as info |
32
32
 
33
+ Nothing here needs doing. If you would rather move onto the current name now,
34
+ `npx cia fix-theme <your-theme.css>` shows what it would change and
35
+ `--write` applies it. It renames the property and nothing else, so the theme
36
+ renders identically; a block already declaring `--page-hero-bg` is reported
37
+ rather than merged.
38
+
33
39
  ### If you want a hero
34
40
 
35
41
  Declare the tokens your theme needs and apply the surface where you want it:
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  **Read [`llm.txt`](./llm.txt) first.** One file, the whole system: install path, hard rules, the mixin vocabulary, and the traps that make agents write wrong cia code. It ships in the npm package, so it's at `node_modules/css-is-awesome/llm.txt` in any project that has cia.
16
16
 
17
- **Then connect the MCP server** and stop guessing at signatures. It answers from the real source — 33 tools covering themes, mixins, functions, tokens, recipes, components, theme validation, theme generation from design tokens and the token mapping itself.
17
+ **Then connect the MCP server** and stop guessing at signatures. It answers from the real source — 34 tools covering themes, mixins, functions, tokens, recipes, components, theme validation, theme generation from design tokens and the token mapping itself.
18
18
 
19
19
  ```json
20
20
  {
@@ -79,7 +79,7 @@ Author your own class names; the mixin handles the styling. Mixins for buttons,
79
79
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/css-is-awesome@1/dist/css-is-awesome.min.css">
80
80
  ```
81
81
 
82
- Theme first (sets the tokens), library second. **No `data-theme` attribute needed** — a single theme file styles the page on its own. Swap the URL to swap the theme; the HTML never changes. Bundle tiers — `dist/tokens.css` (2.2 KB gz, `:where(:root)` vars only), `dist/css-is-awesome.core.min.css` (2.4 KB gz, tokens + resets), `dist/css-is-awesome.min.css` (7.3 KB gz, full).
82
+ Theme first (sets the tokens), library second. **No `data-theme` attribute needed** — a single theme file styles the page on its own. Swap the URL to swap the theme; the HTML never changes. Bundle tiers — `dist/tokens.css` (2.2 KB gz, `:where(:root)` vars only), `dist/css-is-awesome.core.min.css` (2.4 KB gz, tokens + resets), `dist/css-is-awesome.min.css` (8.0 KB gz, full).
83
83
 
84
84
  ### 3. Bare tags (opt-in Pico-mode)
85
85
 
@@ -151,7 +151,7 @@ Full authoring walkthrough: [`/docs/authoring/themes`](https://cssisawesome.com/
151
151
 
152
152
  ## Token contract
153
153
 
154
- Every theme declares the same slots: **surfaces · ink · lines · primary · seal · accent · code · type · space · radius · shadow · blur · glow · motion**. Components read tokens, themes set tokens, nothing else. 127 required, 36 optional.
154
+ Every theme declares the same slots: **surfaces · ink · lines · primary · seal · accent · code · type · space · radius · shadow · blur · glow · motion**. Components read tokens, themes set tokens, nothing else. 127 required, 49 optional.
155
155
 
156
156
  **Themes own the spacing scale.** A theme declares the numbered scale `--space-0` … `--space-9` (contract-required), which is exactly what `cia.space(4)` compiles to — so a theme can ship tighter or airier rhythm without touching a component. The six t-shirt names (`--space-2xs/xs/sm/md/lg/xl`) are optional; the library emits them as references (`--space-md: var(--space-4)`), so they track the numbered scale automatically.
157
157
 
@@ -223,6 +223,7 @@ The CLI also carries the registry and the health check:
223
223
 
224
224
  ```bash
225
225
  npx cia add --list # browse the recipe book
226
+ npx cia fix-theme t.css # move a theme onto current token names (--write to apply)
226
227
  npx cia add bottom-nav # copy a recipe into your project — you own the pattern
227
228
  npx cia analyze src/styles # design-system health: dead cia.* symbols, the
228
229
  # space() scale trap, off-contract tokens (typos),
@@ -303,7 +304,7 @@ The scope is kept narrow: 8 `!important` declarations, all inside `@media print`
303
304
 
304
305
  ## MCP server (for AI agents)
305
306
 
306
- cia ships a Model Context Protocol stdio server (JSON-RPC over stdio, protocol `2024-11-05`) exposing **33 tools** across 8 families (themes, mixins, functions, tokens · 127 required of them, animations, components, recipes, doc readers) plus `assemble_prompt` (context bundles) , `resolve_size` (snap design px values to cia's 4px grid) `validate_theme` (run the real theme validator on CSS you just wrote) `theme_from_tokens` (design-tokens JSON → a complete, validated theme.css) and `get_token_map` (that mapping as data, or how one path resolves). Any MCP-aware client (Claude Code, Cursor, Aider, Gemini, Copilot) can then query cia's real design system — mixin signatures, tokens, themes, recipes — instead of guessing, without grep-walking the repo. Full reference: [`/docs/mcp`](https://cssisawesome.com/docs/mcp/).
307
+ cia ships a Model Context Protocol stdio server (JSON-RPC over stdio, protocol `2024-11-05`) exposing **34 tools** across 8 families (themes, mixins, functions, tokens · 127 required of them, animations, components, recipes, doc readers) plus `assemble_prompt` (context bundles) , `resolve_size` (snap design px values to cia's 4px grid) `validate_theme` (run the real theme validator on CSS you just wrote) `theme_from_tokens` (design-tokens JSON → a complete, validated theme.css) and `get_token_map` (that mapping as data, or how one path resolves). Any MCP-aware client (Claude Code, Cursor, Aider, Gemini, Copilot) can then query cia's real design system — mixin signatures, tokens, themes, recipes — instead of guessing, without grep-walking the repo. Full reference: [`/docs/mcp`](https://cssisawesome.com/docs/mcp/).
307
308
 
308
309
  **Recommended — zero install:** use the dedicated [`css-is-awesome-mcp`](https://www.npmjs.com/package/css-is-awesome-mcp) package. It depends on `css-is-awesome` and resolves your installed version's real source, so it's never out of sync — and the MCP SDK ships as a real dependency, not an optional peer you have to remember to add.
309
310
 
@@ -366,6 +367,8 @@ It also hosts the **[playground](https://cssisawesome.com/playground/)**: write
366
367
  | `npm run build:css:all` | Compile all bundles (full + core + utilities + minified) + themes + token types |
367
368
  | `npm run build:css:themes` | Rebuild the 24 per-theme CSS files in `public/themes/` **and** regenerate the all-in-one `public/theme.css` bundle |
368
369
  | `npm run check:theme-drift` | Rebuild the themes into a scratch copy and fail if the committed artifacts don't match their SCSS sources |
370
+ | `npm run check:contract` | Diff the token contract against the last release tag and fail if the required/optional lists changed without the version bump [`VERSIONING.md`](./VERSIONING.md) requires |
371
+ | `npm run check:rtl` | Audit the SCSS for physical properties that should be logical (`margin-left` → `margin-inline-start`) |
369
372
  | `npm run build:token-types` | Generate `dist/tokens.d.ts` from the contract |
370
373
  | `npm run dtcg-to-scss` | Convert DTCG-format design tokens into cia SCSS |
371
374
  | `npm run lint` | ESLint on the Next.js app |
@@ -374,6 +377,9 @@ It also hosts the **[playground](https://cssisawesome.com/playground/)**: write
374
377
  | `npm run validate-icons` | Validate the `core` icon pack against the 49-glyph contract |
375
378
  | `npm run validate-api` | Assert the `css-is-awesome/api` barrel stays zero-emit |
376
379
  | `npm run validate-package` | Pack + install into a temp project and compile every documented `@use` form — catches breakage that in-repo checks can't see |
380
+ | `npm run test:surfaces` | Assert cia never applies a page surface automatically — the guarantee that adding hero/band tokens changes nobody's existing page |
381
+ | `npm run test:tokens` | Exercise the design-tokens converter (DTCG / Tokens Studio / flat) against fixtures |
382
+ | `npm run verify:playground` | Compile real samples through the playground's in-browser Sass path in Node |
377
383
  | `npm run pack:consumer` | Pack and install this build into a local consumer (defaults to `../boiler-project-ai`); `--dry-run` supported |
378
384
  | `npm test` | Playwright suite — axe a11y checks + per-theme visual snapshots |
379
385
 
@@ -408,18 +414,18 @@ Full detail: [`/docs/testing`](https://cssisawesome.com/docs/testing/).
408
414
 
409
415
  | Bundle | Size | Use case |
410
416
  |---|---|---|
411
- | `dist/tokens.css` | 2.25 KB | Tokens only (`:where(:root)` CSS variables, no rules) — the purest mixin-first emit |
412
- | `dist/css-is-awesome.core.min.css` | 2.38 KB | Tokens + resets, no utilities or components |
413
- | `dist/css-is-awesome.utilities.min.css` | 4.75 KB | Every `cia-*` utility class, nothing else |
414
- | `dist/css-is-awesome.min.css` | 7.96 KB | Full bundle (everything) |
415
- | Per-theme `themes/<name>/theme.css` | 1.9–3.7 KB | One file per theme, both modes via `light-dark()`, drop-in with no markup change |
417
+ | `dist/tokens.css` | 2.26 KB | Tokens only (`:where(:root)` CSS variables, no rules) — the purest mixin-first emit |
418
+ | `dist/css-is-awesome.core.min.css` | 2.42 KB | Tokens + resets, no utilities or components |
419
+ | `dist/css-is-awesome.utilities.min.css` | 4.79 KB | Every `cia-*` utility class, nothing else |
420
+ | `dist/css-is-awesome.min.css` | 7.99 KB | Full bundle (everything) |
421
+ | Per-theme `themes/<name>/theme.css` | 2.0–3.8 KB | One file per theme, both modes via `light-dark()`, drop-in with no markup change |
416
422
  | **Runtime JavaScript shipped in package** | **0 KB** | Nothing in the package is loaded by a page. The Node tooling (`cia` CLI, MCP server, validators) never reaches the browser; JS-driven UI features ship as separate add-on packages. |
417
423
 
418
424
  ## Status
419
425
 
420
426
  **Stable, [published on npm](https://www.npmjs.com/package/css-is-awesome)** (first published 2026-09-01). The mixin API, functions, token contract, and theme architecture are stable and under strict SemVer — breaking changes require a major bump. See [`VERSIONING.md`](./VERSIONING.md) for the policy.
421
427
 
422
- The 1.0 surface is the v0.8 mixin-first reframe — twelve mixin renames, theme system collapsed to 8 single-file theme families, six zero-JS components, intrinsic-layout vocabulary, opt-in utilities — plus the recipes book, the Tailwind/Bootstrap migration on-ramp, print/PDF support, the in-browser [Playground](https://cssisawesome.com/playground/), the design-tokens on-ramp (`npx cia theme from-tokens` / `theme map`), and the 33-tool MCP server (now also available zero-install via the companion [`css-is-awesome-mcp`](https://www.npmjs.com/package/css-is-awesome-mcp) package). The npm package ships ZERO runtime JavaScript by hard rule — nothing in it is loaded by a page; its Node tooling (CLI, MCP server, validators) never reaches the browser.
428
+ The 1.0 surface is the v0.8 mixin-first reframe — twelve mixin renames, theme system collapsed to 8 single-file theme families, six zero-JS components, intrinsic-layout vocabulary, opt-in utilities — plus the recipes book, the Tailwind/Bootstrap migration on-ramp, print/PDF support, the in-browser [Playground](https://cssisawesome.com/playground/), the design-tokens on-ramp (`npx cia theme from-tokens` / `theme map`), and the 34-tool MCP server (now also available zero-install via the companion [`css-is-awesome-mcp`](https://www.npmjs.com/package/css-is-awesome-mcp) package). The npm package ships ZERO runtime JavaScript by hard rule — nothing in it is loaded by a page; its Node tooling (CLI, MCP server, validators) never reaches the browser.
423
429
 
424
430
  See [CHANGELOG.md](./CHANGELOG.md) for the full history and [MIGRATION.md](./MIGRATION.md) for the v0.7 → v0.8 and v0.8 → v1.0 upgrade paths.
425
431
 
package/bin/analyze.cjs CHANGED
@@ -33,6 +33,18 @@ function loadContractTokens() {
33
33
  }
34
34
  }
35
35
 
36
+ // Tokens the contract has superseded. They still resolve — cia deprecates
37
+ // rather than deletes — so this is a warning with a one-command fix, not an
38
+ // error. Read from the same map `cia fix-theme` and the validator use.
39
+ function loadDeprecated() {
40
+ try {
41
+ const c = JSON.parse(fs.readFileSync(CONTRACT_PATH, 'utf8'));
42
+ return c.deprecated && typeof c.deprecated === 'object' ? c.deprecated : {};
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
36
48
  // Tiny Levenshtein (zero-dep). Only called on var(--x) misses, so cost is trivial.
37
49
  function editDistance(a, b) {
38
50
  const m = a.length, n = b.length;
@@ -256,7 +268,7 @@ function missingFocusVisible(strippedSrc) {
256
268
  return [{ level: 'info', rule: 'missing-focus-visible', detail: 'interactive :hover/:active styling with no :focus-visible or focus-ring anywhere in this file — keyboard users may get no visible feedback' }];
257
269
  }
258
270
 
259
- function analyzeFile(file, symbols, contractTokens, extraNs) {
271
+ function analyzeFile(file, symbols, contractTokens, extraNs, deprecated) {
260
272
  const raw = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
261
273
  const src = stripComments(raw);
262
274
  const ns = detectNamespaces(src, extraNs);
@@ -280,6 +292,37 @@ function analyzeFile(file, symbols, contractTokens, extraNs) {
280
292
  }
281
293
  }
282
294
  }
295
+ // deprecated-token: still resolves, but the contract names a successor.
296
+ // Catches both a declaration (--old: value) and a reference (var(--old)).
297
+ // A plain scan, not a built regex: custom-property names are distinctive
298
+ // enough that a substring plus a name-boundary check is exact, and it does
299
+ // not depend on escaping a token name into a pattern.
300
+ if (deprecated && Object.keys(deprecated).length) {
301
+ const isNameChar = (ch) => /[A-Za-z0-9_-]/.test(ch);
302
+ for (const [tok, def] of Object.entries(deprecated)) {
303
+ let at = src.indexOf(tok);
304
+ let hit = false;
305
+ while (at !== -1) {
306
+ const after = src[at + tok.length];
307
+ // Reject a longer name that merely starts with this one.
308
+ if (after === undefined || !isNameChar(after)) { hit = true; break; }
309
+ at = src.indexOf(tok, at + 1);
310
+ }
311
+ if (!hit) continue;
312
+ const to = (def && def.replacedBy) || null;
313
+ let since = '';
314
+ if (def && def.since) {
315
+ since = ' (contract ' + def.since + (def.removeIn ? ', removed in ' + def.removeIn : '') + ')';
316
+ }
317
+ findings.push({
318
+ level: 'warn',
319
+ rule: 'deprecated-token',
320
+ detail: tok + ' is deprecated' + since + (to ? ' — use ' + to : '') +
321
+ '; your value still works. Run `npx cia fix-theme <file>` to rename it.',
322
+ });
323
+ }
324
+ }
325
+
283
326
  // off-contract-token: a var(--x) that's a near-miss of a real contract token
284
327
  // (a typo → the declaration silently fails). Pure custom tokens — not close to
285
328
  // any contract token — are the consumer's own and are left alone.
@@ -325,6 +368,7 @@ const RULE_CATEGORY = {
325
368
  'unknown-symbol': 'API',
326
369
  'space-scale': 'Spacing',
327
370
  'off-contract-token': 'Contract',
371
+ 'deprecated-token': 'Contract',
328
372
  'off-scale-length': 'Spacing',
329
373
  'hard-coded-color': 'Color',
330
374
  bem: 'Naming',
@@ -364,8 +408,9 @@ async function run(args) {
364
408
 
365
409
  const symbols = collectSymbols();
366
410
  const contractTokens = loadContractTokens();
411
+ const deprecated = loadDeprecated();
367
412
  const files = walkScss(target);
368
- const results = files.map((f) => analyzeFile(f, symbols, contractTokens, extraNs)).filter((r) => r.findings.length || r.namespaces.length);
413
+ const results = files.map((f) => analyzeFile(f, symbols, contractTokens, extraNs, deprecated)).filter((r) => r.findings.length || r.namespaces.length);
369
414
 
370
415
  const counts = { error: 0, warn: 0, info: 0 };
371
416
  for (const r of results) for (const f of r.findings) counts[f.level]++;
package/bin/cia.cjs CHANGED
@@ -43,6 +43,9 @@ Commands:
43
43
  theme.css. \`cia theme from-tokens --help\`.
44
44
  theme map The path → token mapping from-tokens applies, as
45
45
  data: a table, \`--json\`, or \`--path <p>\` for one.
46
+ fix-theme <file> Move a theme onto current token names: rewrites
47
+ deprecated tokens to their replacements. Prints by
48
+ default, \`--write\` applies.
46
49
 
47
50
  Examples:
48
51
  cia migrate tailwind ./tailwind.config.js
@@ -50,6 +53,7 @@ Examples:
50
53
  cia analyze src/styles
51
54
  cia theme from-tokens tokens.json --name acme --out src/styles/acme.css
52
55
  cia theme map --path color.text.primary
56
+ cia fix-theme src/styles/acme.css --write
53
57
 
54
58
  Run \`cia <command> --help\` for command-specific help.
55
59
  `;
@@ -158,6 +162,12 @@ async function main() {
158
162
  return;
159
163
  }
160
164
 
165
+ if (command === 'fix-theme') {
166
+ const { run } = require('./fix-theme.cjs');
167
+ await run(rest);
168
+ return;
169
+ }
170
+
161
171
  if (command === 'theme') {
162
172
  const [sub, ...themeArgs] = rest;
163
173
  if (!sub || sub === '-h' || sub === '--help' || sub === 'help') {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * cia fix-theme — rewrite a theme's deprecated tokens to their replacements.
3
+ *
4
+ * The CLI face of scripts/fix-theme.cjs (the MCP tool `fix_theme` and the
5
+ * in-process `handlers.fix_theme` are the same function). cia deprecates a
6
+ * token rather than deleting it, so the old declaration keeps working — this
7
+ * is the one command that moves you onto the current name.
8
+ *
9
+ * Only the property name changes. Values, comments, ordering and whitespace
10
+ * survive byte-for-byte, so applying this cannot alter how the theme looks.
11
+ *
12
+ * Reads and prints by default; writes only with --write.
13
+ *
14
+ * Exit codes: 0 nothing to do or fixed, 1 collisions need a human, 2 usage /
15
+ * input error.
16
+ */
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const HELP = `cia fix-theme — move a theme onto current token names
23
+
24
+ Usage:
25
+ cia fix-theme <theme.css> [options]
26
+
27
+ Options:
28
+ --write Apply the changes to the file (default: print, change nothing)
29
+ --json Machine-readable { css, changes, unchanged, summary }
30
+ -h, --help This text
31
+
32
+ What it does:
33
+ cia deprecates a token instead of deleting it, so your existing declaration
34
+ keeps working. This renames it to the replacement the contract names. Only
35
+ the property name moves — the value, your comments, the ordering and the
36
+ whitespace are untouched, so the rendered theme is identical.
37
+
38
+ If a block already declares the replacement, that line is left alone and
39
+ reported: merging two values is your decision, not the tool's.
40
+
41
+ Examples:
42
+ cia fix-theme src/styles/acme.css # show me what would change
43
+ cia fix-theme src/styles/acme.css --write # do it
44
+ cia fix-theme src/styles/acme.css --json # for a script
45
+ `;
46
+
47
+ const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
48
+ const c = (code, s) => (isTTY ? `[${code}m${s}` : s);
49
+ const bold = (s) => c('1', s);
50
+ const dim = (s) => c('2', s);
51
+ const green = (s) => c('32', s);
52
+ const yellow = (s) => c('33', s);
53
+ const red = (s) => c('31', s);
54
+
55
+ function parseArgs(argv) {
56
+ const out = { file: null, write: false, json: false, help: false };
57
+ for (const a of argv) {
58
+ if (a === '--write') out.write = true;
59
+ else if (a === '--json') out.json = true;
60
+ else if (a === '-h' || a === '--help' || a === 'help') out.help = true;
61
+ else if (a.startsWith('-')) throw new Error(`unknown option '${a}'`);
62
+ else if (out.file == null) out.file = a;
63
+ else throw new Error(`unexpected extra argument '${a}'`);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ async function run(argv) {
69
+ let args;
70
+ try {
71
+ args = parseArgs(argv || []);
72
+ } catch (err) {
73
+ process.stderr.write(`${red('error:')} ${err.message}\n\n${HELP}`);
74
+ process.exitCode = 2;
75
+ return;
76
+ }
77
+ if (args.help || !args.file) {
78
+ process.stdout.write(HELP);
79
+ if (!args.help) process.exitCode = 2;
80
+ return;
81
+ }
82
+
83
+ const abs = path.resolve(args.file);
84
+ let css;
85
+ try {
86
+ css = fs.readFileSync(abs, 'utf8');
87
+ } catch (err) {
88
+ process.stderr.write(`${red('error:')} could not read ${args.file} — ${err.message}\n`);
89
+ process.exitCode = 2;
90
+ return;
91
+ }
92
+
93
+ const { fixTheme } = require(path.join(__dirname, '..', 'scripts', 'fix-theme.cjs'));
94
+ let result;
95
+ try {
96
+ result = fixTheme({ css });
97
+ } catch (err) {
98
+ process.stderr.write(`${red('error:')} ${err.message}\n`);
99
+ process.exitCode = 2;
100
+ return;
101
+ }
102
+
103
+ const rewrites = result.changes.filter((x) => x.kind === 'rewrite');
104
+ const conflicts = result.changes.filter((x) => x.kind === 'conflict');
105
+
106
+ if (args.json) {
107
+ process.stdout.write(
108
+ JSON.stringify(
109
+ { file: args.file, written: args.write && rewrites.length > 0, ...result },
110
+ null,
111
+ 2
112
+ ) + '\n'
113
+ );
114
+ } else {
115
+ const rel = path.relative(process.cwd(), abs) || args.file;
116
+ if (!result.changes.length) {
117
+ process.stdout.write(`${green('✓')} ${bold(rel)} ${dim('— no deprecated tokens; already current')}\n`);
118
+ return;
119
+ }
120
+ process.stdout.write(`${bold(rel)}\n`);
121
+ for (const ch of rewrites) {
122
+ process.stdout.write(` ${green('→')} line ${ch.line}: ${ch.from} ${dim('→')} ${bold(ch.to)}\n`);
123
+ process.stdout.write(` ${dim(ch.reason)}\n`);
124
+ }
125
+ for (const ch of conflicts) {
126
+ process.stdout.write(` ${yellow('!')} line ${ch.line}: ${ch.from} ${dim('left as-is')}\n`);
127
+ process.stdout.write(` ${dim(ch.reason)}\n`);
128
+ }
129
+ }
130
+
131
+ if (args.write && rewrites.length) {
132
+ try {
133
+ fs.writeFileSync(abs, result.css);
134
+ } catch (err) {
135
+ process.stderr.write(`${red('error:')} could not write ${args.file} — ${err.message}\n`);
136
+ process.exitCode = 2;
137
+ return;
138
+ }
139
+ if (!args.json) {
140
+ process.stdout.write(`\n${green('✓')} wrote ${rewrites.length} change(s) to ${bold(path.relative(process.cwd(), abs) || args.file)}\n`);
141
+ }
142
+ } else if (rewrites.length && !args.json) {
143
+ process.stdout.write(`\n${dim('nothing written — re-run with --write to apply')}\n`);
144
+ }
145
+
146
+ // Collisions are the one case a human has to settle, so say so in the exit
147
+ // code too: a pipeline should stop rather than assume the theme is current.
148
+ if (conflicts.length) process.exitCode = 1;
149
+ }
150
+
151
+ module.exports = { run, parseArgs, HELP };
package/llm.txt CHANGED
@@ -49,7 +49,7 @@ silent.
49
49
  - **Mixins** — `cia.btn`, `cia.card`, `cia.accordion`, `cia.modal`, `cia.tooltip`, `cia.dropdown`, `cia.tabs`, `cia.copy-button`, `cia.hamburger`, `cia.drawer`, `cia.sheet`, `cia.dock`, `cia.stepper`, `cia.progress`, `cia.wizard-shell`, `cia.sidebar`, `cia.toolbar`, `cia.stack`, `cia.cluster`, `cia.switcher`, `cia.cover`, `cia.frame`, `cia.media`, `cia.contain`, `cia.focus-ring`, `cia.sr-only`, `cia.print`, `cia.print-base`, `cia.print-hidden`, `cia.print-only`, `cia.color`, `cia.space`, `cia.radius`, `cia.shadow`, `cia.font`, `cia.transition`, `cia.animate`, plus many more. **`cia.print-base` always forces `color-scheme: light` in print (paired `light-dark()` themes print their light branch), defines a themeable print palette (`--print-ink` / `--print-paper` / `--print-line` / `--print-muted`, default ink-on-white with grays) and rebinds the theme's own colour tokens (`--ink`, `--surface-*`, `--border-*`, `--code-*`) onto it — so every theme, dark-only (Terminal) included, prints legible ink-on-paper, and overriding a `--print-*` token restyles paper (a letterhead) without touching a component. Three opt-in flags, all default OFF:** `$link-urls` + `$link-origin` (print each link's full destination via `attr(href)`), `$page-numbers` (sheet numbers in the `@page` footer). `$legible` is a DEPRECATED no-op — the token rebind supersedes it and passing it warns.
50
50
  - **24 themes / 8 families** — boilerplate, sketchbook, press, prism, cupertino, glass, graphite, terminal. Each family ships three files: an unsuffixed dual-mode base (both modes via `light-dark()`) plus pinned `-light` and `-dark` single-mode variants for `<link media>` pairing. Every one has exactly one SCSS source under `scss/themes/`; `public/themes/<name>/theme.css` is BUILD OUTPUT and is gated against its source by `npm run check:theme-drift` — never hand-edit it. MCP `list_themes` returns **24**. Say **24 themes across 8 families** when you need one number.
51
51
  - **6 zero-JS interactive components** — accordion (`<details name>`), modal (`<dialog>`), tooltip (`popover="hint"`), dropdown (`[popover]` — the mixin re-asserts the UA's closed state and restores `display: flex` only under `:popover-open`, so menus never render permanently open on popover markup), tabs (radio + `:has()`), copy-button (Clipboard API via consumer-wired JS).
52
- - **CLI** (`npx cia`) — `migrate tailwind|bootstrap|mui|chakra` (config → cia theme), `theme from-tokens <tokens.json> --name <slug>` (DTCG v2025.10 / Tokens Studio / flat `--token` JSON → a complete theme.css: required tokens the file lacks inherit from a shipped base, unmapped paths pass through and are reported, light/dark pairs become `light-dark()`, validator + WCAG audit run first; same function as the MCP `theme_from_tokens` tool), `theme map [--json | --path <p>]` (the path → token mapping from-tokens applies, as data; same as MCP `get_token_map`), `add <recipe>` (copy a recipe from the book into the project), `analyze [path]` (audit stylesheets against the installed API: dead `cia.*` symbols, the `space()` 1–9 trap, off-contract tokens (near-miss typos of real tokens only, never your own custom tokens), hard-coded hex, BEM; health score, CI exit codes). Low-noise on color: a hex in a `var(--token, #hex)` fallback is token-driven and a literal inside `@media print` is an intentional paper colour — neither is flagged.
52
+ - **CLI** (`npx cia`) — `migrate tailwind|bootstrap|mui|chakra` (config → cia theme), `theme from-tokens <tokens.json> --name <slug>` (DTCG v2025.10 / Tokens Studio / flat `--token` JSON → a complete theme.css: required tokens the file lacks inherit from a shipped base, unmapped paths pass through and are reported, light/dark pairs become `light-dark()`, validator + WCAG audit run first; same function as the MCP `theme_from_tokens` tool), `theme map [--json | --path <p>]` (the path → token mapping from-tokens applies, as data; same as MCP `get_token_map`), `add <recipe>` (copy a recipe from the book into the project), `analyze [path]` (audit stylesheets against the installed API: dead `cia.*` symbols, the `space()` 1–9 trap, off-contract tokens (near-miss typos of real tokens only, never your own custom tokens), hard-coded hex, BEM; health score, CI exit codes). Low-noise on color: a hex in a `var(--token, #hex)` fallback is token-driven and a literal inside `@media print` is an intentional paper colour — neither is flagged. `fix-theme <theme.css> [--write]` renames DEPRECATED tokens to their replacements (property only — values, comments and ordering survive, so the theme renders identically; prints by default, writes with `--write`; a block already declaring the replacement is reported, never merged).
53
53
  - **Mobile navigation family** — `cia.hamburger` / `cia.drawer` / `cia.sheet` / `cia.dock` ride `[popover]` + CSS Grid, zero JS; recipes `mobile-nav` (hamburger + drawer) and `bottom-nav` (dock + sheets). House rule: **on phones things take the space they're in** — a `cia.dropdown` menu opens 1px under its full-width trigger at the trigger's exact width via CSS anchor positioning (`position-try-fallbacks: flip-block` flips it above at the screen bottom; set `width: auto` at `&[popover]` specificity or the UA's `[popover] { width: fit-content }` and the mixin's inset reset win). Full spec: AGENTS.md quick decision #8 and `/docs/mobile`.
54
54
  - **176 contract tokens — 127 required + 49 optional** — surfaces, ink, lines, colors, type, radius, shadow, blur, glow, motion, z-index, spacing, semantic aliases.
55
55
  - **Spacing is themeable, and the numbered scale is the knob.** Themes declare `--space-0` … `--space-9` (contract-required). The t-shirt names (`--space-md`, `--space-lg` …) are contract-OPTIONAL and emitted by the library as `var()` aliases onto the numbered steps. `space(4)` compiles to `var(--space-4)`, so **theme the numbered step, never the alias** — setting `--space-md` alone leaves every component untouched. Library defaults emit under `:where(:root)` (specificity 0,0,0) so any theme declaration outranks them regardless of load order.
@@ -129,7 +129,7 @@ Newspaper by day, hacker terminal by night. Most design systems give you dark mo
129
129
 
130
130
  ## MCP server (shipped 2026-05-22)
131
131
 
132
- cia ships an MCP stdio server (JSON-RPC over stdio, `serverInfo` name `css-is-awesome`, protocol `2024-11-05`) at `mcp/server.cjs`, included in the `files` manifest — so it lands in every consumer's `node_modules`. **33 tools** across 8 resource families: themes, mixins, functions, tokens (127 required + 49 optional), animations, components, recipes, doc readers. Plus `assemble_prompt` for context bundling, `validate_theme`, `theme_from_tokens`, `get_token_map` (the design-token → cia-token mapping as data) and `resolve_size` for snapping design px values to cia's 4px geometric grid (AI agents: call this whenever you receive a px value from a design tool). Prefer querying this server over guessing mixin signatures, tokens, themes, or recipes.
132
+ cia ships an MCP stdio server (JSON-RPC over stdio, `serverInfo` name `css-is-awesome`, protocol `2024-11-05`) at `mcp/server.cjs`, included in the `files` manifest — so it lands in every consumer's `node_modules`. **34 tools** across 8 resource families: themes, mixins, functions, tokens (127 required + 49 optional), animations, components, recipes, doc readers. Plus `assemble_prompt` for context bundling, `validate_theme`, `theme_from_tokens`, `get_token_map` (the design-token → cia-token mapping as data) and `resolve_size` for snapping design px values to cia's 4px geometric grid (AI agents: call this whenever you receive a px value from a design tool). Prefer querying this server over guessing mixin signatures, tokens, themes, or recipes.
133
133
 
134
134
  **Recommended: suggest the dedicated `css-is-awesome-mcp` package** — zero install,
135
135
  SDK ships as a real dependency, no separate peer-install step:
@@ -198,4 +198,4 @@ Humans first. AI's role is to compose, not to lead the pitch.
198
198
 
199
199
  ---
200
200
 
201
- *Generated 2026-05-21 for cia v0.8. Updated 2026-05-23 for the v1.0 architecture lock, 2026-08-30 for the theme single-source rework (24 themes, 127+36 contract, themeable spacing, `:root, :root[data-theme]` selector), 2026-09-04 for the shipped recipes count (5), the mobile-nav mixin family, and the dropdown popover guard, and 2026-09-19 for the 32-recipe book, 33-tool MCP, contract 1.2 (127 + 41 in 10 features), the playground and the design-tokens on-ramp. Update when the API surface changes.*
201
+ *Generated 2026-05-21 for cia v0.8. Updated 2026-05-23 for the v1.0 architecture lock, 2026-08-30 for the theme single-source rework (24 themes, 127+36 contract, themeable spacing, `:root, :root[data-theme]` selector), 2026-09-04 for the shipped recipes count (5), the mobile-nav mixin family, and the dropdown popover guard, and 2026-09-19 for the 32-recipe book, 33-tool MCP, contract 1.2 (127 + 41 in 10 features), the playground and the design-tokens on-ramp, and 2026-09-24 for contract 1.3 (127 required + 49 optional across 11 feature groups) and the page-surfaces hero/band tokens. Update when the API surface changes.*
package/mcp/server.cjs CHANGED
@@ -21,9 +21,10 @@
21
21
  * Sizing: resolve_size
22
22
  * Themes (build): theme_from_tokens — design-tokens JSON → validated theme.css
23
23
  * get_token_map — the path → token mapping as data (or one path)
24
+ * Themes (fix): fix_theme — rewrite deprecated tokens to their replacements
24
25
  * Prompt: assemble_prompt(intent[, args])
25
26
  *
26
- * 33 tools total.
27
+ * 34 tools total.
27
28
  *
28
29
  * Discovery model: filesystem scan, no database. Parses SCSS files with
29
30
  * focused regex (no full SCSS AST). Tokens come from the authoritative
@@ -471,7 +472,8 @@ function loadTokenContract() {
471
472
  (byCategory[category] = byCategory[category] || []).push(t);
472
473
  }
473
474
 
474
- return { required: contract.required, optional, features, byName, byCategory };
475
+ const deprecated = contract.deprecated && typeof contract.deprecated === 'object' ? contract.deprecated : {};
476
+ return { required: contract.required, optional, features, deprecated, byName, byCategory };
475
477
  }
476
478
 
477
479
  /**
@@ -726,14 +728,51 @@ const handlers = {
726
728
  // source token the same way without re-deriving the rules.
727
729
  get_token_map({ path: tokenPath } = {}) {
728
730
  const { tokenMap, resolvePath } = require(path.join(SCRIPTS_DIR, 'tokens-to-theme.cjs'));
729
- if (tokenPath == null || tokenPath === '') return tokenMap({ ciaRoot: PROJECT_ROOT });
731
+ const deprecated = getTokens().deprecated;
732
+ if (tokenPath == null || tokenPath === '') {
733
+ // `deprecated` rides along so an agent translating a design file lands
734
+ // on the CURRENT name in one call, instead of mapping to a token that
735
+ // is on its way out and finding out later.
736
+ return { ...tokenMap({ ciaRoot: PROJECT_ROOT }), deprecated };
737
+ }
730
738
  const r = resolvePath(String(tokenPath), { ciaRoot: PROJECT_ROOT });
731
739
  const entry = getTokens().byName[r.token] || null;
740
+ const dep = deprecated[r.token] || null;
732
741
  return {
733
742
  ...r,
734
743
  required: entry ? entry.required : null,
735
744
  feature: entry && !entry.required ? (entry.feature || null) : null,
736
745
  category: entry ? entry.category : null,
746
+ deprecated: dep ? { replacedBy: dep.replacedBy || null, since: dep.since || null, removeIn: dep.removeIn || null, note: dep.note || null } : null,
747
+ };
748
+ },
749
+
750
+ // Rewrite a theme's DEPRECATED token declarations to their replacements.
751
+ // Returns the corrected text and a per-line account of what moved and why;
752
+ // it never touches disk, so an agent can show the diff, ask, and only then
753
+ // write. Renames the property only — a value is never altered, so applying
754
+ // this cannot change how the theme looks.
755
+ fix_theme({ css, apply } = {}) {
756
+ if (typeof css !== 'string' || !css.trim()) {
757
+ throw new Error('fix_theme: css is required (the compiled theme CSS, not .scss source)');
758
+ }
759
+ const { fixTheme, deprecations } = require(path.join(SCRIPTS_DIR, 'fix-theme.cjs'));
760
+ const result = fixTheme({ css });
761
+ const rewrites = result.changes.filter((c) => c.kind === 'rewrite');
762
+ const conflicts = result.changes.filter((c) => c.kind === 'conflict');
763
+ return {
764
+ ...result,
765
+ // `apply` is the caller's stated intent, echoed back. This tool has no
766
+ // filesystem access in either case — saying so plainly stops an agent
767
+ // reporting "I updated your theme" when nothing was written.
768
+ applied: false,
769
+ requestedApply: Boolean(apply),
770
+ summary: result.unchanged
771
+ ? (conflicts.length
772
+ ? `Nothing rewritten: ${conflicts.length} collision(s) need a human decision.`
773
+ : 'No deprecated tokens found — this theme is already current.')
774
+ : `${rewrites.length} declaration(s) renamed${conflicts.length ? `, ${conflicts.length} left for you to resolve` : ''}. Write the returned css yourself; nothing was saved.`,
775
+ knownDeprecations: deprecations(),
737
776
  };
738
777
  },
739
778
 
@@ -876,10 +915,16 @@ const handlers = {
876
915
  referencedBy.push({ name: d.name, kind: d.kind, path: d.path });
877
916
  }
878
917
  }
918
+ const dep = getTokens().deprecated[entry.name] || null;
879
919
  return {
880
920
  name: entry.name,
881
921
  category: entry.category,
882
922
  required: entry.required,
923
+ // Contract 1.3+: null unless this token has been superseded. The old
924
+ // value keeps working — `fix_theme` renames it when the caller asks.
925
+ deprecated: dep
926
+ ? { replacedBy: dep.replacedBy || null, since: dep.since || null, removeIn: dep.removeIn || null, note: dep.note || null }
927
+ : null,
883
928
  // Contract 1.2: the feature an OPTIONAL token enables (null for required).
884
929
  feature: entry.required ? null : (entry.feature || null),
885
930
  themeValues,
@@ -1417,6 +1462,22 @@ async function startServer() {
1417
1462
  },
1418
1463
  }, async (a) => ok(handlers.theme_from_tokens(a || {})));
1419
1464
 
1465
+ server.registerTool('fix_theme', {
1466
+ description:
1467
+ 'Upgrade a theme that uses a DEPRECATED token. cia deprecates a token rather than deleting it, so the ' +
1468
+ 'old declaration keeps working — but there is a better name now, and this rewrites it for you. Pass the ' +
1469
+ 'compiled theme CSS; get back { css, changes, unchanged, summary } where every change names the line, the ' +
1470
+ 'old token, its replacement and why it moved. Only the property name changes — values, comments, ordering ' +
1471
+ 'and whitespace survive byte-for-byte, so applying it cannot alter how the theme looks. If a block already ' +
1472
+ 'declares the replacement the old line is left alone and reported as a conflict, because merging two values ' +
1473
+ 'is a judgement call. NOTHING IS WRITTEN TO DISK: this returns text, and the caller decides whether to save ' +
1474
+ 'it. Use get_token to see what supersedes a given token, or read knownDeprecations in the result.',
1475
+ inputSchema: {
1476
+ css: z.string().describe('Compiled theme CSS — the :root/[data-theme] block(s), not .scss source.'),
1477
+ apply: z.boolean().optional().describe('Your stated intent, echoed back as requestedApply. This tool cannot write files either way; you save the returned css yourself.'),
1478
+ },
1479
+ }, async (a) => ok(handlers.fix_theme(a || {})));
1480
+
1420
1481
  server.registerTool('get_token_map', {
1421
1482
  description:
1422
1483
  'The design-token → cia-token mapping that theme_from_tokens applies, as data. Without `path`: ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "css-is-awesome",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "A token-driven SCSS design system with light/dark theming, semantic color tokens, and a 800+ LOC mixin API.",
5
5
  "homepage": "https://github.com/Jerry2d3d/css-is-awesome#readme",
6
6
  "bugs": {
@@ -74,6 +74,7 @@
74
74
  "scripts/theme-contract.json",
75
75
  "scripts/theme-validator.js",
76
76
  "scripts/tokens-to-theme.cjs",
77
+ "scripts/fix-theme.cjs",
77
78
  "scripts/theme-a11y.js",
78
79
  "scripts/audit-pairs.json",
79
80
  "scripts/icon-validator.js",
@@ -123,6 +124,7 @@
123
124
  "validate-package": "node scripts/validate-package.mjs",
124
125
  "validate-recipes": "node scripts/validate-recipes.mjs",
125
126
  "test:tokens": "node --test scripts/test-tokens-to-theme.mjs",
127
+ "test:fix-theme": "node --test scripts/test-fix-theme.mjs",
126
128
  "size-budget": "node scripts/size-budget.mjs",
127
129
  "size-report": "node scripts/size-budget.mjs --report",
128
130
  "coverage:api": "node scripts/api-coverage.mjs",
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // fix-theme.cjs
4
+ // ============================================================================
5
+ // Rewrite a theme's DEPRECATED token declarations to their replacements.
6
+ //
7
+ // WHY THIS EXISTS
8
+ // Until now the tooling could only *say* a token was deprecated. A consumer
9
+ // then had to find it, look up what replaced it, and edit by hand — for a
10
+ // rename that a machine can do exactly. `scripts/theme-contract.json` already
11
+ // carries the `deprecated` map (old token → replacedBy / since / removeIn /
12
+ // note); this turns that map into an offer: here is what changed, here is the
13
+ // corrected file, say the word and it is yours.
14
+ //
15
+ // WHAT IT WILL NOT DO
16
+ // * It never writes to disk. It returns text. The caller decides — same
17
+ // contract as themeFromTokens(), for the same reason: a tool that edits
18
+ // a consumer's file as a side effect of being asked a question is a tool
19
+ // nobody can trust in a pipeline.
20
+ // * It never changes a VALUE. Only the property name on the left of the
21
+ // colon moves. A rename cannot alter how the theme looks.
22
+ // * It never duplicates. If a block already declares the replacement, the
23
+ // old line is left exactly where it is and the collision is reported,
24
+ // because merging two values is a judgement call, not a rewrite.
25
+ //
26
+ // FORMATTING
27
+ // Line-based on purpose. A CSS parser would round-trip the file through an
28
+ // AST and quietly restyle whitespace, comment placement and declaration
29
+ // order — a diff full of noise around the one line that mattered. Here the
30
+ // bytes either side of the property name survive untouched.
31
+ //
32
+ // Usage (library):
33
+ // const { fixTheme } = require('css-is-awesome/scripts/fix-theme.cjs');
34
+ // const { css, changes, unchanged } = fixTheme({ css: themeText });
35
+ // ============================================================================
36
+ 'use strict';
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+
41
+ const CONTRACT_PATH = path.join(__dirname, 'theme-contract.json');
42
+
43
+ /** Load the token contract, or throw something a human can act on. */
44
+ function loadContract(ciaRoot) {
45
+ const p = ciaRoot ? path.join(ciaRoot, 'scripts', 'theme-contract.json') : CONTRACT_PATH;
46
+ let raw;
47
+ try {
48
+ raw = fs.readFileSync(p, 'utf8');
49
+ } catch (err) {
50
+ throw new Error(`fix-theme: could not read the token contract at ${p} — ${err.message}`);
51
+ }
52
+ try {
53
+ return JSON.parse(raw);
54
+ } catch (err) {
55
+ throw new Error(`fix-theme: the token contract at ${p} is not valid JSON — ${err.message}`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The deprecations as a flat, serialisable list — the same data `get_token`
61
+ * and the validator report, so all three can never disagree.
62
+ */
63
+ function deprecations(opts) {
64
+ const contract = (opts && opts.contract) || loadContract(opts && opts.ciaRoot);
65
+ const map = contract.deprecated && typeof contract.deprecated === 'object' ? contract.deprecated : {};
66
+ return Object.entries(map).map(([token, d]) => ({
67
+ token,
68
+ replacedBy: (d && d.replacedBy) || null,
69
+ since: (d && d.since) || null,
70
+ removeIn: (d && d.removeIn) || null,
71
+ note: (d && d.note) || null,
72
+ }));
73
+ }
74
+
75
+ // A custom-property declaration: leading whitespace, the name, optional
76
+ // whitespace, the colon. Everything from the colon rightwards is untouched.
77
+ const DECL = /^(\s*)(--[A-Za-z0-9_-]+)(\s*):/;
78
+
79
+ // Strip /* … */ so a commented-out declaration is never counted as declared
80
+ // nor rewritten. Length is preserved so line numbers stay honest.
81
+ function blankComments(text) {
82
+ return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
83
+ }
84
+
85
+ /**
86
+ * Rewrite deprecated declarations.
87
+ *
88
+ * @param {object} o
89
+ * @param {string} o.css theme CSS (compiled, not .scss source)
90
+ * @param {object} [o.contract] pre-loaded contract (tests / callers with one)
91
+ * @param {string} [o.ciaRoot] resolve the contract from another install
92
+ * @returns {{css:string, changes:Array, unchanged:boolean, deprecatedFound:string[]}}
93
+ * changes[] entries are `{ line, from, to, reason, kind }` where kind is
94
+ * 'rewrite' (applied) or 'conflict' (left alone, and why).
95
+ */
96
+ function fixTheme(o) {
97
+ const opts = o || {};
98
+ if (typeof opts.css !== 'string') {
99
+ throw new Error('fix-theme: css is required and must be a string (compiled CSS, not .scss source)');
100
+ }
101
+ const contract = opts.contract || loadContract(opts.ciaRoot);
102
+ const map = contract.deprecated && typeof contract.deprecated === 'object' ? contract.deprecated : {};
103
+
104
+ const lines = opts.css.split('\n');
105
+ const scan = blankComments(opts.css).split('\n');
106
+
107
+ // ── Pass 1: which tokens does each brace-scope already declare? ──────────
108
+ // Scope id changes on every `{`, so two theme blocks in one bundled file
109
+ // are judged independently — a collision in `[data-theme="a"]` says nothing
110
+ // about `[data-theme="b"]`.
111
+ const scopeOf = new Array(lines.length).fill(0);
112
+ const declaredIn = new Map(); // scopeId -> Set<token>
113
+ {
114
+ let depth = 0;
115
+ const stack = [0];
116
+ let next = 1;
117
+ for (let i = 0; i < scan.length; i++) {
118
+ const line = scan[i];
119
+ scopeOf[i] = stack[stack.length - 1];
120
+ const m = DECL.exec(line);
121
+ if (m) {
122
+ const id = stack[stack.length - 1];
123
+ if (!declaredIn.has(id)) declaredIn.set(id, new Set());
124
+ declaredIn.get(id).add(m[2]);
125
+ }
126
+ for (const ch of line) {
127
+ if (ch === '{') { stack.push(next++); depth++; }
128
+ else if (ch === '}') { if (stack.length > 1) stack.pop(); depth--; }
129
+ }
130
+ }
131
+ }
132
+
133
+ // ── Pass 2: rewrite, or explain why not ─────────────────────────────────
134
+ const changes = [];
135
+ const found = new Set();
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const m = DECL.exec(scan[i]);
138
+ if (!m) continue;
139
+ const token = m[2];
140
+ const dep = map[token];
141
+ if (!dep || !dep.replacedBy) continue;
142
+
143
+ found.add(token);
144
+ const replacement = dep.replacedBy;
145
+ const already = declaredIn.get(scopeOf[i]);
146
+
147
+ if (already && already.has(replacement)) {
148
+ changes.push({
149
+ line: i + 1,
150
+ from: token,
151
+ to: replacement,
152
+ kind: 'conflict',
153
+ reason:
154
+ `${replacement} is already declared in this block, so renaming ${token} would ` +
155
+ `produce two declarations of the same property and silently pick the last one. ` +
156
+ `Left untouched — decide which value you want and delete the other.`,
157
+ });
158
+ continue;
159
+ }
160
+
161
+ // Rename only the property. Indentation, spacing before the colon, the
162
+ // value, the semicolon and any trailing comment all survive byte-exact.
163
+ lines[i] = lines[i].replace(DECL, (_full, indent, _name, gap) => `${indent}${replacement}${gap}:`);
164
+ changes.push({
165
+ line: i + 1,
166
+ from: token,
167
+ to: replacement,
168
+ kind: 'rewrite',
169
+ reason:
170
+ `${token} is deprecated since contract ${dep.since || '?'} and is removed in ` +
171
+ `${dep.removeIn || 'a future major'}. ${dep.note || ''}`.trim(),
172
+ });
173
+ }
174
+
175
+ const rewrites = changes.filter((c) => c.kind === 'rewrite');
176
+ return {
177
+ css: rewrites.length ? lines.join('\n') : opts.css,
178
+ changes,
179
+ unchanged: rewrites.length === 0,
180
+ deprecatedFound: [...found].sort(),
181
+ };
182
+ }
183
+
184
+ module.exports = { fixTheme, deprecations, loadContract };
@@ -354,7 +354,24 @@ function validateTokenSet(declared, contract) {
354
354
  const f = featureOf[t] || 'other';
355
355
  (optionalMissingByFeature[f] = optionalMissingByFeature[f] || []).push(t);
356
356
  }
357
- return { ok: missing.length === 0, missing, optionalMissing, optionalMissingByFeature, declaredCount: declared.size };
357
+ // Contract 1.3+: a token this theme declares that has since been superseded.
358
+ // Reported separately from the optional-token info line — "you declared
359
+ // something that still works but has a better name now" is a different
360
+ // message from "you left a default to the library", and it comes with an
361
+ // action: `cia fix-theme`.
362
+ const deprecatedMap = contract.deprecated && typeof contract.deprecated === 'object' ? contract.deprecated : {};
363
+ const deprecatedUsed = [];
364
+ for (const [token, def] of Object.entries(deprecatedMap)) {
365
+ if (declared.has(token)) {
366
+ deprecatedUsed.push({
367
+ token,
368
+ replacedBy: (def && def.replacedBy) || null,
369
+ since: (def && def.since) || null,
370
+ removeIn: (def && def.removeIn) || null,
371
+ });
372
+ }
373
+ }
374
+ return { ok: missing.length === 0, missing, optionalMissing, optionalMissingByFeature, deprecatedUsed, declaredCount: declared.size };
358
375
  }
359
376
 
360
377
  // -----------------------------------------------------------
@@ -380,6 +397,7 @@ function validateText(text, contract, options) {
380
397
  missing: [],
381
398
  optionalMissing: [],
382
399
  optionalMissingByFeature: {},
400
+ deprecatedUsed: [],
383
401
  themes: null,
384
402
  a11y: null,
385
403
  error: null,
@@ -403,6 +421,7 @@ function validateText(text, contract, options) {
403
421
  missing: v.missing,
404
422
  optionalMissing: v.optionalMissing,
405
423
  optionalMissingByFeature: v.optionalMissingByFeature,
424
+ deprecatedUsed: v.deprecatedUsed,
406
425
  a11y: null,
407
426
  };
408
427
  if (wantA11y) theme.a11y = a11y.auditThemeTokens({ name: b.name, values: b.values });
@@ -432,6 +451,7 @@ function validateText(text, contract, options) {
432
451
  result.missing = v.missing;
433
452
  result.optionalMissing = v.optionalMissing;
434
453
  result.optionalMissingByFeature = v.optionalMissingByFeature;
454
+ result.deprecatedUsed = v.deprecatedUsed;
435
455
  result.ok = v.ok;
436
456
  if (wantA11y) {
437
457
  const inferredName = label !== '(pasted CSS)' ? (path.basename(path.dirname(label)) || path.basename(label, '.css')) : 'theme';
@@ -471,6 +491,21 @@ function relForDisplay(p) {
471
491
 
472
492
  // Optional tokens a theme leaves to the library default. Info only — shown as
473
493
  // a count, or listed with --show-optional. Never affects the exit code.
494
+ // A token the theme declares that has been superseded. Never a failure — the
495
+ // old value still works — but it names the replacement and the way to apply it.
496
+ function deprecationInfo(deprecatedUsed, indent) {
497
+ const list = Array.isArray(deprecatedUsed) ? deprecatedUsed : [];
498
+ if (!list.length) return;
499
+ for (const d of list) {
500
+ console.log(
501
+ `${indent}${yellow('~')} ${d.token} is deprecated` +
502
+ (d.since ? ` (since contract ${d.since}` + (d.removeIn ? `, removed in ${d.removeIn}` : '') + ')' : '') +
503
+ (d.replacedBy ? ` — use ${d.replacedBy}` : '')
504
+ );
505
+ console.log(`${indent} ${dim('your value still works; `npx cia fix-theme <file>` renames it for you')}`);
506
+ }
507
+ }
508
+
474
509
  function optionalInfo(optionalMissing, byFeature, indent) {
475
510
  const list = Array.isArray(optionalMissing) ? optionalMissing : [];
476
511
  if (!list.length) return;
@@ -506,6 +541,7 @@ function reportResult(result) {
506
541
  ` ${green('✓')} [data-theme="${t.name}"] ${dim(`(${t.declaredCount} tokens)`)}`
507
542
  );
508
543
  optionalInfo(t.optionalMissing, t.optionalMissingByFeature, ' ');
544
+ deprecationInfo(t.deprecatedUsed, ' ');
509
545
  } else {
510
546
  const n = t.missing.length;
511
547
  console.log(
@@ -514,6 +550,7 @@ function reportResult(result) {
514
550
  for (const token of t.missing) {
515
551
  console.log(` ${red(token)}`);
516
552
  }
553
+ deprecationInfo(t.deprecatedUsed, ' ');
517
554
  }
518
555
  }
519
556
  return;
@@ -525,6 +562,7 @@ function reportResult(result) {
525
562
  `${green('✓')} ${bold(rel)} ${dim(`passes (${result.declaredCount} tokens declared)`)}`
526
563
  );
527
564
  optionalInfo(result.optionalMissing, result.optionalMissingByFeature, ' ');
565
+ deprecationInfo(result.deprecatedUsed, ' ');
528
566
  return;
529
567
  }
530
568
 
@@ -535,6 +573,7 @@ function reportResult(result) {
535
573
  for (const token of result.missing) {
536
574
  console.log(` ${red(token)}`);
537
575
  }
576
+ deprecationInfo(result.deprecatedUsed, ' ');
538
577
  }
539
578
 
540
579
  // -----------------------------------------------------------