css-is-awesome 1.16.1 → 1.17.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
@@ -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 **31 tools** across 8 families:
341
+ Either way it exposes **32 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)
@@ -348,11 +348,11 @@ Either way it exposes **31 tools** across 8 families:
348
348
  - **Components** — `list_components`, `get_component`, `search_components`
349
349
  - **Recipes** — `list_recipes`, `get_recipe`
350
350
  - **Doc readers** — `read_llm_txt`, `read_changelog`, `read_migration`, `read_theming`, `read_agents`, `read_contract`, `read_three_tiers`, `read_readme`, `read_versioning`
351
- - **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)
351
+ - **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`)
352
352
 
353
353
  ## Other tooling (shipped)
354
354
 
355
- - **`cia` CLI** — ships as `bin/cia.cjs`, exposed as the `cia` bin. Three verbs:
355
+ - **`cia` CLI** — ships as `bin/cia.cjs`, exposed as the `cia` bin. Four verbs:
356
356
  `npx cia migrate tailwind|bootstrap [path]` parses another system's config and
357
357
  dumps a cia theme; `npx cia add <recipe>` (`--list` to browse) copies a recipe
358
358
  from the book into the project — own the pattern; `npx cia analyze [path]`
@@ -360,9 +360,14 @@ Either way it exposes **31 tools** across 8 families:
360
360
  the `space()` 1–9 scale trap, off-contract tokens (near-miss typos only), hard-coded
361
361
  hex colors, BEM chains — with a health score and CI-ready exit codes. Low-noise on
362
362
  color: a hex in a `var(--token, #hex)` fallback is token-driven, and a literal inside
363
- `@media print` is an intentional paper colour — neither is flagged. Run any verb with `--help`. (`cia init` remains
363
+ `@media print` is an intentional paper colour — neither is flagged; `npx cia theme from-tokens <tokens.json> --name <slug>`
364
+ turns a design-tokens file (DTCG v2025.10, Tokens Studio for Figma, or a flat `--token` map; format auto-detected)
365
+ into a complete theme.css — every required token the file lacks inherits from a shipped base theme (`--base`,
366
+ default boilerplate), unmapped paths pass through verbatim and are reported, a `--dark` file or paired
367
+ `color-light`/`color-dark` groups become `light-dark()`, and the validator + WCAG audit run before anything is
368
+ written. Same function as the MCP `theme_from_tokens` tool. Run any verb with `--help`. (`cia init` remains
364
369
  planned.)
365
- - **JSON token export** — DTCG-format token list in `figma-tokens/`.
370
+ - **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).
366
371
  - **`llm.txt`** — at the repo root and served from the docs site; single-fetch
367
372
  summary for any AI agent. Also readable over MCP via `read_llm_txt`.
368
373
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ # [1.17.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.16.1...v1.17.0) (2026-09-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** cia theme from-tokens — design-tokens JSON → validated theme.css ([066b484](https://github.com/Jerry2d3d/css-is-awesome/commit/066b4849d2e937f0362e8f21cb212f3fcc144c5f))
9
+ * **mcp:** theme_from_tokens tool + in-process handler (32 tools) ([9dc90c4](https://github.com/Jerry2d3d/css-is-awesome/commit/9dc90c40574a5e21b7b724f97792b0fd9c4182b0))
10
+
3
11
  ## [1.16.1](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.16.0...v1.16.1) (2026-09-18)
4
12
 
5
13
 
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 — 31 tools covering themes, mixins, functions, tokens, recipes, components and theme validation.
17
+ **Then connect the MCP server** and stop guessing at signatures. It answers from the real source — 32 tools covering themes, mixins, functions, tokens, recipes, components, theme validation and theme generation from design tokens.
18
18
 
19
19
  ```json
20
20
  {
@@ -226,6 +226,10 @@ npx cia analyze src/styles # design-system health: dead cia.* symbols, the
226
226
  # space() scale trap, off-contract tokens (typos),
227
227
  # off-scale lengths, hard-coded colors, BEM creep,
228
228
  # missing focus-visible styling
229
+ npx cia theme from-tokens tokens.json --name acme --out src/styles/acme.css
230
+ # design tokens (DTCG v2025.10, Tokens Studio, or a
231
+ # flat --token map) → a complete, validated theme.css;
232
+ # missing required tokens inherit from a shipped base
229
233
  ```
230
234
 
231
235
  `cia analyze` reads the real API surface from the installed package and exits non-zero on errors, so it slots straight into CI. It's deliberately low-noise about color: a hex used as a `var(--token, #hex)` fallback is token-driven (not flagged), and a literal inside `@media print` is an intentional paper colour (print escapes theme colours by design). Off-contract-token findings only fire on a **near-miss** of a real token — a typo like `--inkk` — never on your own custom tokens. Off-scale-length findings suggest the nearest named step (e.g. `--radius-md`) for a literal `border-radius`/`padding`/`margin`/`gap` value, as a hint toward using a token — never a claim about your active theme's exact pixel value, since themes are free to set their own numbers (Terminal sets every `--radius-*` to `0`, deliberately). The default output is a **graded report** — a health score, a section per concern (Contract / Spacing / Color / Naming / Layout / API / Accessibility) with a `✓` when clean, and a suggested fix on each finding; add `--verbose` for the flat per-file list or `--json` for the machine shape. Full rule reference: [`/docs/analyzer`](https://cssisawesome.com/docs/analyzer/).
@@ -294,7 +298,7 @@ The scope is kept narrow: 8 `!important` declarations, all inside `@media print`
294
298
 
295
299
  ## MCP server (for AI agents)
296
300
 
297
- cia ships a Model Context Protocol stdio server (JSON-RPC over stdio, protocol `2024-11-05`) exposing **31 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) and `validate_theme` (run the real theme validator on CSS you just wrote). 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/).
301
+ cia ships a Model Context Protocol stdio server (JSON-RPC over stdio, protocol `2024-11-05`) exposing **32 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) and `theme_from_tokens` (design-tokens JSON → a complete, validated theme.css). 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/).
298
302
 
299
303
  **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.
300
304
 
@@ -410,7 +414,7 @@ Full detail: [`/docs/testing`](https://cssisawesome.com/docs/testing/).
410
414
 
411
415
  **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.
412
416
 
413
- 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, and the 31-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 JavaScript by hard rule.
417
+ 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, and the 32-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 JavaScript by hard rule.
414
418
 
415
419
  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.
416
420
 
package/bin/cia.cjs CHANGED
@@ -11,6 +11,7 @@
11
11
  * migrate bootstrap — parse Bootstrap SCSS/CSS vars + dump theme JSON
12
12
  * migrate mui — parse a MUI createTheme() result + dump theme JSON
13
13
  * migrate chakra — parse a Chakra extendTheme() result + dump theme JSON
14
+ * theme from-tokens — design-tokens JSON (DTCG / Tokens Studio) → validated theme.css
14
15
  *
15
16
  * cia core ships ZERO JavaScript in the `files` manifest. The CLI lives in
16
17
  * `bin/` which is explicitly allowed per the architecture lock — same path
@@ -36,11 +37,15 @@ Commands:
36
37
  analyze [path] Design-system health check: dead cia.* symbols,
37
38
  the space() scale trap, hard-coded colors, BEM,
38
39
  hand-written area maps.
40
+ theme from-tokens <f> Design-tokens JSON (DTCG v2025.10, Tokens Studio,
41
+ or flat --token map) → a complete, validated
42
+ theme.css. \`cia theme from-tokens --help\`.
39
43
 
40
44
  Examples:
41
45
  cia migrate tailwind ./tailwind.config.js
42
46
  cia add bottom-nav
43
47
  cia analyze src/styles
48
+ cia theme from-tokens tokens.json --name acme --out src/styles/acme.css
44
49
 
45
50
  Run \`cia <command> --help\` for command-specific help.
46
51
  `;
@@ -149,6 +154,20 @@ async function main() {
149
154
  return;
150
155
  }
151
156
 
157
+ if (command === 'theme') {
158
+ const [sub, ...themeArgs] = rest;
159
+ if (!sub || sub === '-h' || sub === '--help' || sub === 'help') {
160
+ process.stdout.write(require('./theme-from-tokens.cjs').HELP);
161
+ return;
162
+ }
163
+ if (sub === 'from-tokens') {
164
+ const { run } = require('./theme-from-tokens.cjs');
165
+ await run(themeArgs);
166
+ return;
167
+ }
168
+ fail(`unknown theme subcommand '${sub}'. Available: from-tokens.`);
169
+ }
170
+
152
171
  fail(`unknown command '${command}'. Run \`cia --help\` for usage.`);
153
172
  }
154
173
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * cia theme from-tokens — design-tokens JSON → validated cia theme.css.
3
+ *
4
+ * The CLI face of scripts/tokens-to-theme.cjs (the MCP tool
5
+ * `theme_from_tokens` and the in-process `handlers.theme_from_tokens` are the
6
+ * same function). Zero dependencies, no Sass: the theme is emitted as CSS in
7
+ * the exact shape the shipped themes use, then the shipped validator + WCAG
8
+ * audit run on it before anything is written.
9
+ *
10
+ * Exit codes: 0 valid (or --no-validate), 1 contract/a11y failure (a11y only
11
+ * unless --allow-a11y-fail), 2 usage / input error.
12
+ */
13
+ 'use strict';
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const HELP = `cia theme from-tokens — design-tokens JSON → validated cia theme.css
18
+
19
+ Usage:
20
+ cia theme from-tokens <tokens.json> --name <slug> [options]
21
+
22
+ Options:
23
+ --name <slug> Theme name (kebab-case). Required.
24
+ --format <f> auto | dtcg | tokens-studio | cia-flat (default: auto)
25
+ --base <theme> Shipped theme that supplies every REQUIRED token the file
26
+ does not (default: boilerplate). \`--base list\` prints them.
27
+ --dark <dark.json> Second tokens file for dark mode → light-dark() values.
28
+ --mode light|dark Single-mode color-scheme when no dark side (default: light).
29
+ --out <theme.css> Write the CSS here (default: stdout).
30
+ --json Print { report, validation } as JSON to stdout
31
+ (CSS goes to --out only).
32
+ --no-validate Skip the validator + WCAG audit.
33
+ --allow-a11y-fail Do not exit non-zero on a11y FAILs (report only).
34
+
35
+ Formats:
36
+ dtcg DTCG v2025.10 — leaves carry $value (+ $type); {aliases} resolve.
37
+ tokens-studio Tokens Studio for Figma — leaves carry value + type; single set
38
+ or multi-set export ($metadata.tokenSetOrder); {aliases} resolve.
39
+ Paired top-level groups (color-light + color-dark, or
40
+ light + dark) become light-dark() automatically.
41
+ cia-flat { "--brand-primary": "#3A5FCD", ... } passthrough.
42
+
43
+ Minimum content: none. Whatever the file does not supply is inherited from the
44
+ base theme and listed in the report, so the output is always contract-complete.
45
+
46
+ Examples:
47
+ cia theme from-tokens tokens.json --name acme --out src/styles/acme.css
48
+ cia theme from-tokens light.json --dark dark.json --name acme --json
49
+ cia theme from-tokens brand.json --name acme --base press --format dtcg
50
+ `;
51
+
52
+ function parseArgs(argv) {
53
+ const opts = { format: 'auto', base: 'boilerplate', mode: 'light', validate: true, allowA11yFail: false, json: false };
54
+ const positional = [];
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ const next = () => { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); return v; };
58
+ if (a === '-h' || a === '--help') opts.help = true;
59
+ else if (a === '--name') opts.name = next();
60
+ else if (a === '--format') opts.format = next();
61
+ else if (a === '--base') opts.base = next();
62
+ else if (a === '--dark') opts.dark = next();
63
+ else if (a === '--mode') opts.mode = next();
64
+ else if (a === '--out') opts.out = next();
65
+ else if (a === '--json') opts.json = true;
66
+ else if (a === '--no-validate') opts.validate = false;
67
+ else if (a === '--allow-a11y-fail') opts.allowA11yFail = true;
68
+ else if (a.startsWith('-')) throw new Error(`unknown option ${a}`);
69
+ else positional.push(a);
70
+ }
71
+ opts.input = positional[0];
72
+ return opts;
73
+ }
74
+
75
+ function readJson(file) {
76
+ const abs = path.resolve(file);
77
+ let text;
78
+ try { text = fs.readFileSync(abs, 'utf8'); } catch (e) { throw new Error(`cannot read ${file}: ${e.message}`); }
79
+ try { return JSON.parse(text); } catch (e) { throw new Error(`${file} is not valid JSON: ${e.message}`); }
80
+ }
81
+
82
+ async function run(argv) {
83
+ let opts;
84
+ try { opts = parseArgs(argv); } catch (e) { process.stderr.write(`cia theme: ${e.message}\n`); process.exit(2); }
85
+ if (opts.help || !opts.input && opts.base !== 'list') { process.stdout.write(HELP); return; }
86
+
87
+ const { themeFromTokens, listBases } = require('../scripts/tokens-to-theme.cjs');
88
+ if (opts.base === 'list') { process.stdout.write(listBases(path.join(__dirname, '..')).join('\n') + '\n'); return; }
89
+ if (!opts.name) { process.stderr.write('cia theme: --name <slug> is required\n'); process.exit(2); }
90
+
91
+ let result;
92
+ try {
93
+ result = themeFromTokens({
94
+ tokens: readJson(opts.input),
95
+ dark: opts.dark ? readJson(opts.dark) : undefined,
96
+ name: opts.name,
97
+ format: opts.format,
98
+ base: opts.base,
99
+ mode: opts.mode,
100
+ validate: opts.validate,
101
+ });
102
+ } catch (e) {
103
+ process.stderr.write(`cia theme: ${e.message}\n`);
104
+ process.exit(2);
105
+ }
106
+
107
+ const { css, report, validation } = result;
108
+ if (opts.out) fs.writeFileSync(path.resolve(opts.out), css, 'utf8');
109
+
110
+ if (opts.json) {
111
+ process.stdout.write(JSON.stringify({ report, validation, out: opts.out || null }, null, 2) + '\n');
112
+ } else {
113
+ if (!opts.out) process.stdout.write(css);
114
+ const c = report.counts;
115
+ const lines = [
116
+ `theme "${report.name}" — ${report.format}${report.darkMode ? ' (light + dark)' : ''} · base ${report.base}`,
117
+ ` from tokens ${c.fromTokens}`,
118
+ ` inherited ${c.inherited} (required tokens the file did not supply)`,
119
+ ` passthrough ${c.unmapped} (not contract tokens — emitted verbatim)`,
120
+ c.skipped ? ` skipped ${c.skipped} (composite values with no single-token home)` : null,
121
+ opts.out ? ` written ${opts.out}` : null,
122
+ ].filter(Boolean);
123
+ process.stderr.write(lines.join('\n') + '\n');
124
+ if (validation) {
125
+ const { reportResult, reportA11yForTheme } = require('../scripts/theme-validator.js');
126
+ reportResult(validation);
127
+ const themes = validation.mode === 'consolidated' ? validation.themes : [{ name: report.name, a11y: validation.a11y }];
128
+ for (const t of themes) if (t.a11y) reportA11yForTheme(t.name, t.a11y, ' ');
129
+ }
130
+ }
131
+
132
+ if (validation) {
133
+ const a11yFail = validation.a11ySummary ? validation.a11ySummary.fail : 0;
134
+ if (!validation.ok) process.exit(1);
135
+ if (a11yFail && !opts.allowA11yFail) process.exit(1);
136
+ }
137
+ }
138
+
139
+ module.exports = { run, parseArgs, HELP };
package/dist/tokens.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Generated from scripts/theme-contract.json on 2026-09-18.
1
+ // Generated from scripts/theme-contract.json on 2026-09-19.
2
2
  // Do not edit by hand. Run `npm run build:token-types` to regenerate.
3
3
 
4
4
  /** Every CSS custom property cia themes are required to declare. */
@@ -22,6 +22,21 @@ The `.scss` siblings exist for consumers who want to `@import` Figma-exported
22
22
  tokens directly without going through `tokens.json`. They are not consumed by
23
23
  the main `scss/` build — `scss/main.scss` pulls from `scss/theme/`.
24
24
 
25
+ ## Round trip: `tokens.json` → a theme
26
+
27
+ `tokens.json` is a Tokens Studio export and is consumed by the design-tokens
28
+ on-ramp shipped since 1.17.0:
29
+
30
+ ```bash
31
+ npx cia theme from-tokens node_modules/css-is-awesome/figma-tokens/tokens.json --name figma-demo --out figma-demo.css
32
+ ```
33
+
34
+ Its paired `color-light` / `color-dark` groups become one `light-dark()` theme;
35
+ `brand`, `spacing`, `font-*`, `line-height`, `border-radius` and `shadow` map by
36
+ the generic path rule; anything without a contract home (e.g. `brand.accent`)
37
+ is emitted verbatim and listed in the report. The same function is the MCP
38
+ `theme_from_tokens` tool. Input contract: `/docs/authoring/themes#from-design-tokens`.
39
+
25
40
  ## Direction of truth
26
41
 
27
42
  **Hand-maintained.** Keep in sync with `scss/theme/` when tokens change.
package/llm.txt CHANGED
@@ -48,7 +48,7 @@ silent.
48
48
  - **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.
49
49
  - **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.
50
50
  - **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).
51
- - **CLI** (`npx cia`) — `migrate tailwind|bootstrap` (config → cia theme), `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.
51
+ - **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), `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
52
  - **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`.
53
53
  - **168 contract tokens — 127 required + 41 optional** — surfaces, ink, lines, colors, type, radius, shadow, blur, glow, motion, z-index, spacing, semantic aliases.
54
54
  - **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.
@@ -128,7 +128,7 @@ Newspaper by day, hacker terminal by night. Most design systems give you dark mo
128
128
 
129
129
  ## MCP server (shipped 2026-05-22)
130
130
 
131
- 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`. **30 tools** across 8 resource families: themes, mixins, functions, tokens (127 required + 36 optional), animations, components, recipes, doc readers. Plus `assemble_prompt` for context bundling 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.
131
+ 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`. **32 tools** across 8 resource families: themes, mixins, functions, tokens (127 required + 36 optional), animations, components, recipes, doc readers. Plus `assemble_prompt` for context bundling 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
132
 
133
133
  **Recommended: suggest the dedicated `css-is-awesome-mcp` package** — zero install,
134
134
  SDK ships as a real dependency, no separate peer-install step:
package/mcp/server.cjs CHANGED
@@ -19,9 +19,10 @@
19
19
  * read_theming, read_agents, read_contract,
20
20
  * read_three_tiers, read_readme, read_versioning
21
21
  * Sizing: resolve_size
22
+ * Themes (build): theme_from_tokens — design-tokens JSON → validated theme.css
22
23
  * Prompt: assemble_prompt(intent[, args])
23
24
  *
24
- * 31 tools total.
25
+ * 32 tools total.
25
26
  *
26
27
  * Discovery model: filesystem scan, no database. Parses SCSS files with
27
28
  * focused regex (no full SCSS AST). Tokens come from the authoritative
@@ -698,6 +699,18 @@ const handlers = {
698
699
  return validateText(css, loadContract(), { label: label || undefined });
699
700
  },
700
701
 
702
+ // Design-tokens JSON (DTCG v2025.10 / Tokens Studio / flat --token map) →
703
+ // a complete theme.css in the shipped shape, validated + contrast-audited.
704
+ // Same function as `cia theme from-tokens`; reachable in-process through
705
+ // module.exports.handlers so an inventory builder can call it without a
706
+ // transport. See scripts/tokens-to-theme.cjs for the input contract.
707
+ theme_from_tokens({ tokens, name, format, base, dark, mode, validate } = {}) {
708
+ if (tokens == null) throw new Error('theme_from_tokens: tokens is required (object or JSON string)');
709
+ if (!name) throw new Error('theme_from_tokens: name is required');
710
+ const { themeFromTokens } = require(path.join(SCRIPTS_DIR, 'tokens-to-theme.cjs'));
711
+ return themeFromTokens({ tokens, name, format, base, dark, mode, validate });
712
+ },
713
+
701
714
  // ─── Mixins ────────────────────────────────────────────────────────────
702
715
 
703
716
  list_mixins({ category, component, limit = 500, offset = 0 } = {}) {
@@ -1354,6 +1367,27 @@ async function startServer() {
1354
1367
  },
1355
1368
  }, async (a) => ok(handlers.validate_theme(a || {})));
1356
1369
 
1370
+ server.registerTool('theme_from_tokens', {
1371
+ description:
1372
+ 'Build a complete, validated cia theme.css from a design-tokens JSON — DTCG v2025.10 ({ $value, $type }, ' +
1373
+ '{aliases} resolved), a Tokens Studio for Figma export ({ value, type }, single or multi-set), or a flat ' +
1374
+ '{ "--token": value } map. Format is auto-detected. Every REQUIRED contract token the file does not supply ' +
1375
+ 'is inherited from a shipped base theme (default boilerplate) and listed in report.inherited, so the output ' +
1376
+ 'is always contract-complete; unmapped paths are emitted verbatim and listed in report.unmapped, never ' +
1377
+ 'dropped. Pass `dark` (same format) or a single file with paired color-light/color-dark groups to get ' +
1378
+ 'light-dark() values. Returns { css, report, validation } — validation is the same result validate_theme ' +
1379
+ 'gives, run on the CSS before you write it anywhere.',
1380
+ inputSchema: {
1381
+ tokens: z.union([z.record(z.any()), z.string()]).describe('The tokens JSON (object, or a JSON string).'),
1382
+ name: z.string().describe('Theme name — kebab-case slug, becomes [data-theme="<name>"].'),
1383
+ format: z.enum(['auto', 'dtcg', 'tokens-studio', 'cia-flat']).optional().describe('Default auto.'),
1384
+ base: z.string().optional().describe('Shipped theme that supplies missing required tokens. Default boilerplate.'),
1385
+ dark: z.union([z.record(z.any()), z.string()]).optional().describe('Optional dark-mode tokens (same format) → light-dark() values.'),
1386
+ mode: z.enum(['light', 'dark']).optional().describe('Single-mode color-scheme when there is no dark side. Default light.'),
1387
+ validate: z.boolean().optional().describe('Run the validator + WCAG audit (default true).'),
1388
+ },
1389
+ }, async (a) => ok(handlers.theme_from_tokens(a || {})));
1390
+
1357
1391
  // Mixins
1358
1392
  server.registerTool('list_mixins', {
1359
1393
  description: `List all ${MIXIN_COUNT} public @mixins across core, layout, animation, icons, generator, per-component and recipe sources. Filter by category (core/layout/animation/icons/generator/component/recipe) or component name.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "css-is-awesome",
3
- "version": "1.16.1",
3
+ "version": "1.17.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": {
@@ -69,6 +69,7 @@
69
69
  "mcp",
70
70
  "scripts/theme-contract.json",
71
71
  "scripts/theme-validator.js",
72
+ "scripts/tokens-to-theme.cjs",
72
73
  "scripts/theme-a11y.js",
73
74
  "scripts/audit-pairs.json",
74
75
  "scripts/icon-validator.js",
@@ -117,6 +118,7 @@
117
118
  "validate-api": "node scripts/validate-api.mjs",
118
119
  "validate-package": "node scripts/validate-package.mjs",
119
120
  "validate-recipes": "node scripts/validate-recipes.mjs",
121
+ "test:tokens": "node --test scripts/test-tokens-to-theme.mjs",
120
122
  "size-budget": "node scripts/size-budget.mjs",
121
123
  "size-report": "node scripts/size-budget.mjs --report",
122
124
  "coverage:api": "node scripts/api-coverage.mjs",
@@ -713,6 +713,10 @@ module.exports = {
713
713
  validateFile,
714
714
  validateText,
715
715
  loadContract,
716
+ // Reporters — reused by `cia theme from-tokens` so its output reads exactly
717
+ // like `npm run validate-themes`.
718
+ reportResult,
719
+ reportA11yForTheme,
716
720
  a11y: a11y,
717
721
  };
718
722
 
@@ -0,0 +1,668 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // tokens-to-theme.cjs — design-tokens JSON → validated cia theme.css
4
+ // ============================================================================
5
+ // The supported path from a design tool to a cia theme. Ships in the npm
6
+ // package (see `files`), zero dependencies, Node ≥ 20. Three front doors share
7
+ // this one module:
8
+ //
9
+ // CLI npx cia theme from-tokens tokens.json --name acme (bin/theme-from-tokens.cjs)
10
+ // MCP theme_from_tokens({ tokens, name }) (mcp/server.cjs)
11
+ // In-proc require('css-is-awesome/mcp/server.cjs').handlers.theme_from_tokens(...)
12
+ //
13
+ // INPUT FORMATS (`format`)
14
+ // auto detect: keys starting with `--` → cia-flat; any `$value` → dtcg;
15
+ // otherwise tokens-studio.
16
+ // dtcg DTCG v2025.10 — nested groups, leaves carry `$value` (+ `$type`).
17
+ // Aliases `{group.path}` resolve recursively (cycle-safe; an
18
+ // unresolved alias is an error, never a silent drop). `$extensions`
19
+ // and other `$`-keys are ignored. 2025.10 composite values are
20
+ // understood: color objects ({hex} or {colorSpace, components}),
21
+ // dimension/duration objects ({value, unit}), shadow objects/arrays,
22
+ // fontFamily arrays, cubicBezier arrays.
23
+ // tokens-studio Tokens Studio for Figma export — leaves carry `value` + `type`.
24
+ // Either a single set (like figma-tokens/tokens.json here) or the
25
+ // multi-set export with top-level `$themes` / `$metadata`; sets merge
26
+ // in `$metadata.tokenSetOrder` (later sets win). Aliases `{path}`
27
+ // (and legacy `$path`) resolve the same way.
28
+ // cia-flat `{ "--brand-primary": "#3A5FCD", ... }` — passthrough.
29
+ //
30
+ // PATH → TOKEN RESOLUTION (in order; first hit wins)
31
+ // 1. TOKEN_MAP — the explicit table below for Figma-style paths that don't
32
+ // spell the cia name (typography.font.body → --font-sans, …).
33
+ // 2. Generic rule — strip a leading `color.`/`colors.`, rewrite a few common
34
+ // group names (PATH_ALIASES: spacing.→space., border-radius.→radius., …),
35
+ // then join the segments with `-`, prefix `--`, and accept the name if it
36
+ // is in the contract (required or optional). So `space.4` → `--space-4`,
37
+ // `brand.primary` → `--brand-primary`, `text.primary` → `--text-primary`.
38
+ // 3. Anything else is emitted VERBATIM as `--<joined-path>` and listed in
39
+ // report.unmapped — never dropped silently.
40
+ //
41
+ // MINIMUM CONTENT
42
+ // None beyond what you have. Every REQUIRED token the file does not supply is
43
+ // inherited from a shipped base theme (`base`, default boilerplate) and listed
44
+ // in report.inherited; optional tokens are never inherited (the library
45
+ // default applies). The output is always a complete, contract-valid theme —
46
+ // the validator + WCAG audit run on it and come back as `validation`.
47
+ //
48
+ // LIGHT + DARK
49
+ // Pass `dark` (a second tokens object, same format) and every colour token
50
+ // that differs becomes `light-dark(light, dark)` with `color-scheme: light dark`.
51
+ // A single Tokens Studio file with paired top-level groups (`color-light` +
52
+ // `color-dark`, or `light` + `dark`) is split the same way automatically.
53
+ // Without a dark side the block is single-mode: `color-scheme: <mode>`
54
+ // (`mode`, default light).
55
+ // ============================================================================
56
+ 'use strict';
57
+
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+
61
+ const CIA_ROOT_DEFAULT = path.resolve(__dirname, '..');
62
+ const GENERATOR_VERSION = (() => {
63
+ try { return require(path.join(CIA_ROOT_DEFAULT, 'package.json')).version; } catch { return 'unknown'; }
64
+ })();
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // 1. Explicit table — Figma-style paths whose cia name is not the joined path.
68
+ // (Everything that IS the joined path — brand.primary, space.4, shadow.md,
69
+ // duration.fast, z.modal, code.bg, … — needs no entry: rule 2 covers it.)
70
+ // ---------------------------------------------------------------------------
71
+ const TOKEN_MAP = {
72
+ // Surfaces / backgrounds — original table (kept verbatim)
73
+ 'color.background.default': '--background-default',
74
+ 'color.background.subtle': '--background-subtle',
75
+ 'color.background.elevated': '--background-elevated',
76
+ 'color.background.overlay': '--background-overlay',
77
+ 'color.background.hero': '--background-hero',
78
+ 'color.background.scrim': '--background-scrim',
79
+ 'color.surface.default': '--surface-default',
80
+ 'color.surface.raised': '--surface-raised',
81
+ 'color.surface.muted': '--surface-muted',
82
+ // Text
83
+ 'color.text.primary': '--text-primary',
84
+ 'color.text.secondary': '--text-secondary',
85
+ 'color.text.muted': '--text-muted',
86
+ 'color.text.inverse': '--text-inverse',
87
+ 'color.text.link': '--text-link',
88
+ // Action
89
+ 'color.action.primary.default': '--action-primary-default',
90
+ 'color.action.primary.hover': '--action-primary-hover',
91
+ 'color.action.primary.active': '--action-primary-active',
92
+ 'color.action.secondary.default': '--action-secondary-default',
93
+ 'color.action.secondary.hover': '--action-secondary-hover',
94
+ 'color.action.secondary.active': '--action-secondary-active',
95
+ // Status
96
+ 'color.success.default': '--success-default',
97
+ 'color.success.subtle': '--success-subtle',
98
+ 'color.warning.default': '--warning-default',
99
+ 'color.warning.subtle': '--warning-subtle',
100
+ 'color.error.default': '--error-default',
101
+ 'color.error.subtle': '--error-subtle',
102
+ // Border
103
+ 'color.border.default': '--border-default',
104
+ 'color.border.subtle': '--border-subtle',
105
+ 'color.border.focus': '--border-focus',
106
+ // Type
107
+ 'typography.font.display': '--font-display',
108
+ 'typography.font.body': '--font-sans',
109
+ 'typography.font.mono': '--font-mono',
110
+ 'typography.font.serif': '--font-serif',
111
+ // Shape
112
+ 'shape.radius.sm': '--radius-sm',
113
+ 'shape.radius.md': '--radius-md',
114
+ 'shape.radius.lg': '--radius-lg',
115
+ 'shape.radius.full': '--radius-full',
116
+ // Spacing aliases
117
+ 'space.xs': '--space-xs',
118
+ 'space.sm': '--space-sm',
119
+ 'space.md': '--space-md',
120
+ 'space.lg': '--space-lg',
121
+ 'space.xl': '--space-xl',
122
+
123
+ // ── Additions 2026-09-18 (Gremlin Forge item 1) — every REQUIRED token
124
+ // whose natural Figma path does not spell the cia name ─────────────
125
+ // Type — Figma tools group families/sizes/weights under typography.*
126
+ 'typography.font.primary': '--font-primary',
127
+ 'typography.font.sans': '--font-sans',
128
+ 'typography.font.script': '--font-script',
129
+ 'typography.family.display': '--font-display',
130
+ 'typography.family.body': '--font-sans',
131
+ 'typography.family.sans': '--font-sans',
132
+ 'typography.family.serif': '--font-serif',
133
+ 'typography.family.mono': '--font-mono',
134
+ 'typography.family.script': '--font-script',
135
+ 'typography.family.primary': '--font-primary',
136
+ 'typography.size.base': '--font-size-base',
137
+ 'typography.fontSize.base': '--font-size-base',
138
+ 'typography.weight.medium': '--font-weight-medium',
139
+ 'typography.fontWeight.medium': '--font-weight-medium',
140
+ 'typography.lineHeight.normal': '--line-height-normal',
141
+ 'typography.line-height.normal': '--line-height-normal',
142
+ // Shape — the fifth radius step + the short r-* aliases some kits export
143
+ 'shape.radius.xl': '--radius-xl',
144
+ 'shape.radius.r-sm': '--r-sm',
145
+ 'shape.radius.r-md': '--r-md',
146
+ 'shape.radius.r-lg': '--r-lg',
147
+ // Colour ramps — the tertiary action, washes, interactive states
148
+ 'color.action.tertiary.default': '--action-tertiary-default',
149
+ 'color.action.tertiary.hover': '--action-tertiary-hover',
150
+ 'color.action.tertiary.active': '--action-tertiary-active',
151
+ 'color.action.primary.wash': '--action-primary-wash',
152
+ 'color.action.secondary.wash': '--action-secondary-wash',
153
+ 'color.action.tertiary.wash': '--action-tertiary-wash',
154
+ 'color.interactive.hover': '--interactive-hover',
155
+ 'color.interactive.active': '--interactive-active',
156
+ 'color.text.tertiary': '--text-tertiary',
157
+ 'color.text.link.hover': '--text-link-hover',
158
+ 'color.text.linkHover': '--text-link-hover',
159
+ 'color.border.emphasis': '--border-emphasis',
160
+ 'color.surface.subtle': '--surface-subtle',
161
+ 'color.surface.emphasis': '--surface-emphasis',
162
+ 'color.surface.glass': '--surface-glass',
163
+ 'color.surface.sunk': '--surface-sunk',
164
+ 'color.background.navbar': '--background-navbar',
165
+ 'color.success.text': '--success-text',
166
+ 'color.warning.text': '--warning-text',
167
+ 'color.error.text': '--error-text',
168
+ 'color.info.default': '--info-default',
169
+ 'color.info.subtle': '--info-subtle',
170
+ 'color.info.text': '--info-text',
171
+ 'color.feedback.success': '--feedback-success',
172
+ 'color.feedback.warning': '--feedback-warning',
173
+ 'color.feedback.error': '--feedback-error',
174
+ 'color.feedback.info': '--feedback-info',
175
+ 'color.brand.primary': '--brand-primary',
176
+ 'color.brand.primary.hover': '--brand-primary-hover',
177
+ 'color.brand.primaryHover': '--brand-primary-hover',
178
+ // Motion
179
+ 'motion.duration.fast': '--duration-fast',
180
+ 'motion.duration.normal': '--duration-normal',
181
+ 'motion.duration.slow': '--duration-slow',
182
+ 'motion.easing': '--ease',
183
+ 'motion.ease': '--ease',
184
+ 'motion.easing.default': '--ease',
185
+ // Effects
186
+ 'effect.shadow.sm': '--shadow-sm',
187
+ 'effect.shadow.md': '--shadow-md',
188
+ 'effect.shadow.lg': '--shadow-lg',
189
+ 'effect.shadow.xl': '--shadow-xl',
190
+ 'effect.shadow.2xl': '--shadow-2xl',
191
+ 'effect.blur.sm': '--blur-sm',
192
+ 'effect.blur.md': '--blur-md',
193
+ 'effect.blur.lg': '--blur-lg',
194
+ 'effect.glow.sm': '--glow-sm',
195
+ 'effect.glow.md': '--glow-md',
196
+ 'effect.glow.lg': '--glow-lg',
197
+ // Layering
198
+ 'layer.dropdown': '--z-dropdown',
199
+ 'layer.sticky': '--z-sticky',
200
+ 'layer.backdrop': '--z-backdrop',
201
+ 'layer.modal': '--z-modal',
202
+ 'layer.popover': '--z-popover',
203
+ 'layer.tooltip': '--z-tooltip',
204
+ };
205
+
206
+ // Group-name rewrites applied before the generic rule (rule 2). Case-insensitive
207
+ // on the first segment only; the rest of the path is joined verbatim.
208
+ const PATH_ALIASES = [
209
+ [/^colors?\./i, ''],
210
+ [/^spacing\./i, 'space.'],
211
+ [/^border-?radius\./i, 'radius.'],
212
+ [/^radii\./i, 'radius.'],
213
+ [/^elevation\./i, 'shadow.'],
214
+ [/^shadows\./i, 'shadow.'],
215
+ [/^z-?index\./i, 'z.'],
216
+ [/^zindex\./i, 'z.'],
217
+ [/^layer\./i, 'z.'],
218
+ [/^font-?family\./i, 'font.'],
219
+ [/^fontFamilies\./i, 'font.'],
220
+ [/^fonts\./i, 'font.'],
221
+ [/^font-?size\./i, 'font-size.'],
222
+ [/^fontSizes?\./i, 'font-size.'],
223
+ [/^font-?weight\./i, 'font-weight.'],
224
+ [/^fontWeights?\./i, 'font-weight.'],
225
+ [/^line-?height\./i, 'line-height.'],
226
+ [/^lineHeights?\./i, 'line-height.'],
227
+ [/^durations?\./i, 'duration.'],
228
+ [/^motion\.duration\./i, 'duration.'],
229
+ ];
230
+
231
+ // Token families that are unitless by contract — a bare number stays bare.
232
+ const UNITLESS_RE = /^--(line-height|font-weight|z-|opacity)/;
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Format detection + flattening
236
+ // ---------------------------------------------------------------------------
237
+ function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
238
+
239
+ function detectFormat(tokens) {
240
+ if (!isPlainObject(tokens)) throw new Error('tokens must be a JSON object');
241
+ const keys = Object.keys(tokens);
242
+ if (keys.length && keys.every((k) => k.startsWith('--'))) return 'cia-flat';
243
+ if (hasKeyDeep(tokens, '$value')) return 'dtcg';
244
+ return 'tokens-studio';
245
+ }
246
+
247
+ function hasKeyDeep(obj, key, depth = 0) {
248
+ if (!isPlainObject(obj) || depth > 12) return false;
249
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return true;
250
+ return Object.values(obj).some((v) => hasKeyDeep(v, key, depth + 1));
251
+ }
252
+
253
+ /** DTCG: leaves are objects carrying `$value`; `$`-prefixed keys are metadata. */
254
+ function flattenDtcg(obj, prefix = '', out = []) {
255
+ for (const [key, val] of Object.entries(obj)) {
256
+ if (key.startsWith('$')) continue;
257
+ const p = prefix ? `${prefix}.${key}` : key;
258
+ if (isPlainObject(val)) {
259
+ if ('$value' in val) out.push({ path: p, value: val.$value, type: val.$type ? String(val.$type).toLowerCase() : undefined });
260
+ else flattenDtcg(val, p, out);
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /** Tokens Studio: leaves are objects carrying `value` (+ `type`). */
267
+ function flattenTokensStudio(obj, prefix = '', out = []) {
268
+ for (const [key, val] of Object.entries(obj)) {
269
+ if (key.startsWith('$')) continue;
270
+ const p = prefix ? `${prefix}.${key}` : key;
271
+ if (isPlainObject(val)) {
272
+ if ('value' in val && !isPlainObject(val.value) || ('value' in val && 'type' in val)) {
273
+ out.push({ path: p, value: val.value, type: val.type ? String(val.type).toLowerCase() : undefined });
274
+ } else {
275
+ flattenTokensStudio(val, p, out);
276
+ }
277
+ }
278
+ }
279
+ return out;
280
+ }
281
+
282
+ /** Tokens Studio multi-set export → one merged set (later sets override). */
283
+ function mergeTokensStudioSets(root) {
284
+ const meta = root.$metadata;
285
+ const setNames = Object.keys(root).filter((k) => !k.startsWith('$'));
286
+ const looksMultiSet = isPlainObject(meta) && Array.isArray(meta.tokenSetOrder) || Array.isArray(root.$themes);
287
+ if (!looksMultiSet) return { set: root, sets: null };
288
+ const order = isPlainObject(meta) && Array.isArray(meta.tokenSetOrder) ? meta.tokenSetOrder.filter((n) => setNames.includes(n)) : setNames;
289
+ const missing = setNames.filter((n) => !order.includes(n));
290
+ const merged = {};
291
+ for (const name of [...order, ...missing]) deepMerge(merged, root[name]);
292
+ return { set: merged, sets: [...order, ...missing] };
293
+ }
294
+
295
+ function deepMerge(target, src) {
296
+ if (!isPlainObject(src)) return target;
297
+ for (const [k, v] of Object.entries(src)) {
298
+ if (isPlainObject(v) && isPlainObject(target[k]) && !('value' in v) && !('$value' in v)) deepMerge(target[k], v);
299
+ else target[k] = v;
300
+ }
301
+ return target;
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Alias resolution — `{group.path}` anywhere in a string, or a whole-value
306
+ // legacy `$group.path`. Recursive, cycle-safe, loud on failure.
307
+ // ---------------------------------------------------------------------------
308
+ function resolveAliases(entries) {
309
+ const byPath = new Map(entries.map((e) => [e.path, e]));
310
+ const resolved = new Map();
311
+
312
+ function resolveValue(value, stack) {
313
+ if (typeof value === 'string') {
314
+ const whole = value.match(/^\$([A-Za-z0-9_.-]+)$/);
315
+ if (whole) return resolvePath(whole[1], stack);
316
+ return value.replace(/\{([^{}]+)\}/g, (_m, ref) => {
317
+ const v = resolvePath(ref.trim(), stack);
318
+ return typeof v === 'string' || typeof v === 'number' ? String(v) : JSON.stringify(v);
319
+ });
320
+ }
321
+ if (Array.isArray(value)) return value.map((v) => resolveValue(v, stack));
322
+ if (isPlainObject(value)) {
323
+ const out = {};
324
+ for (const [k, v] of Object.entries(value)) out[k] = resolveValue(v, stack);
325
+ return out;
326
+ }
327
+ return value;
328
+ }
329
+
330
+ function resolvePath(ref, stack) {
331
+ if (resolved.has(ref)) return resolved.get(ref);
332
+ if (stack.includes(ref)) throw new Error(`alias cycle: ${[...stack, ref].join(' → ')}`);
333
+ const target = byPath.get(ref);
334
+ if (!target) throw new Error(`unresolved alias {${ref}} (referenced from ${stack[stack.length - 1] || 'top level'})`);
335
+ const v = resolveValue(target.value, [...stack, ref]);
336
+ resolved.set(ref, v);
337
+ return v;
338
+ }
339
+
340
+ return entries.map((e) => ({ ...e, value: resolveValue(e.value, [e.path]) }));
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Value normalisation — every leaf becomes one CSS value string, or is skipped
345
+ // with a reason (composites such as `typography` have no single-token home).
346
+ // ---------------------------------------------------------------------------
347
+ function unitOf(type) {
348
+ const t = (type || '').toLowerCase();
349
+ if (['duration', 'durations'].includes(t)) return 'ms';
350
+ if (['dimension', 'spacing', 'sizing', 'borderradius', 'borderwidth', 'fontsize', 'fontsizes', 'letterspacing', 'paragraphspacing', 'space', 'radius'].includes(t)) return 'px';
351
+ return null;
352
+ }
353
+
354
+ function dimToString(v, fallbackUnit) {
355
+ if (isPlainObject(v) && 'value' in v) return `${v.value}${v.unit || fallbackUnit || ''}`;
356
+ if (typeof v === 'number') return `${v}${fallbackUnit || ''}`;
357
+ // Tokens Studio exports numbers as strings ("4"); a bare numeric string takes the unit too.
358
+ if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) return `${v.trim()}${fallbackUnit || ''}`;
359
+ return String(v);
360
+ }
361
+
362
+ function colorToString(v) {
363
+ if (typeof v === 'string') return v.trim();
364
+ if (isPlainObject(v)) {
365
+ if (v.hex) return String(v.hex);
366
+ const space = String(v.colorSpace || 'srgb').toLowerCase();
367
+ const c = Array.isArray(v.components) ? v.components : [];
368
+ const a = v.alpha == null ? 1 : Number(v.alpha);
369
+ if (space === 'srgb' && c.length >= 3) {
370
+ const [r, g, b] = c.map((x) => Math.round(Number(x) * 255));
371
+ return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
372
+ }
373
+ return `color(${space} ${c.join(' ')}${a < 1 ? ` / ${a}` : ''})`;
374
+ }
375
+ return String(v);
376
+ }
377
+
378
+ function shadowToString(v) {
379
+ const one = (s) => {
380
+ if (typeof s === 'string') return s;
381
+ if (!isPlainObject(s)) return String(s);
382
+ const x = dimToString(s.offsetX ?? s.x ?? 0, 'px');
383
+ const y = dimToString(s.offsetY ?? s.y ?? 0, 'px');
384
+ const blur = dimToString(s.blur ?? 0, 'px');
385
+ const spread = dimToString(s.spread ?? 0, 'px');
386
+ const color = colorToString(s.color ?? 'rgba(0,0,0,0.2)');
387
+ const inset = s.inset || String(s.type || '').toLowerCase() === 'innershadow' ? 'inset ' : '';
388
+ return `${inset}${x} ${y} ${blur} ${spread} ${color}`;
389
+ };
390
+ return (Array.isArray(v) ? v : [v]).map(one).join(', ');
391
+ }
392
+
393
+ function fontFamilyToString(v) {
394
+ const list = Array.isArray(v) ? v : String(v).split(',').map((s) => s.trim());
395
+ return list.map((f) => (/[\s]/.test(f) && !/^["']/.test(f) ? `"${f}"` : f)).join(', ');
396
+ }
397
+
398
+ /** Returns { value } or { skip: reason }. */
399
+ function normalizeValue(entry, tokenName) {
400
+ const { value, type } = entry;
401
+ const t = (type || '').toLowerCase();
402
+ if (value == null) return { skip: 'empty value' };
403
+ if (['typography', 'border', 'composition', 'strokestyle', 'transition', 'gradient'].includes(t)) return { skip: `composite type "${t}" has no single-token home` };
404
+ if (t === 'color') return { value: colorToString(value) };
405
+ if (t === 'shadow' || t === 'boxshadow') return { value: shadowToString(value) };
406
+ if (t === 'fontfamily' || t === 'fontfamilies') return { value: fontFamilyToString(value) };
407
+ if (t === 'cubicbezier' && Array.isArray(value)) return { value: `cubic-bezier(${value.join(', ')})` };
408
+ const unit = unitOf(t);
409
+ if (unit) {
410
+ if (isPlainObject(value)) return { value: dimToString(value, unit) };
411
+ if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value).trim())) {
412
+ const n = Number(value);
413
+ return { value: n === 0 ? '0' : `${n}${UNITLESS_RE.test(tokenName) ? '' : unit}` };
414
+ }
415
+ return { value: String(value).trim() };
416
+ }
417
+ if (isPlainObject(value)) {
418
+ if ('hex' in value || 'colorSpace' in value) return { value: colorToString(value) };
419
+ if ('value' in value && 'unit' in value) return { value: dimToString(value) };
420
+ return { skip: 'object value of unknown shape' };
421
+ }
422
+ if (Array.isArray(value)) return { value: value.map(String).join(', ') };
423
+ return { value: String(value).trim() };
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Path → cia token name
428
+ // ---------------------------------------------------------------------------
429
+ function loadContract(ciaRoot) {
430
+ const p = path.join(ciaRoot, 'scripts', 'theme-contract.json');
431
+ const c = JSON.parse(fs.readFileSync(p, 'utf8'));
432
+ return {
433
+ version: c.version,
434
+ required: c.required || [],
435
+ optional: c.optional || [],
436
+ all: new Set([...(c.required || []), ...(c.optional || [])]),
437
+ };
438
+ }
439
+
440
+ function applyPathAliases(p) {
441
+ let out = p;
442
+ for (const [re, to] of PATH_ALIASES) {
443
+ if (re.test(out)) { out = out.replace(re, to); break; }
444
+ }
445
+ return out;
446
+ }
447
+
448
+ /** Returns { token, mapped: true } or { token, mapped: false } (verbatim fallback). */
449
+ function mapPath(tokenPath, contract) {
450
+ if (TOKEN_MAP[tokenPath]) return { token: TOKEN_MAP[tokenPath], mapped: true };
451
+ const aliased = applyPathAliases(tokenPath);
452
+ if (TOKEN_MAP[aliased]) return { token: TOKEN_MAP[aliased], mapped: true };
453
+ const generic = `--${aliased.replace(/\./g, '-').toLowerCase()}`;
454
+ if (contract.all.has(generic)) return { token: generic, mapped: true };
455
+ // camelCase segments → kebab (linkHover → link-hover), one more try.
456
+ const kebab = `--${aliased.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/\./g, '-').toLowerCase()}`;
457
+ if (contract.all.has(kebab)) return { token: kebab, mapped: true };
458
+ return { token: generic, mapped: false };
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Base theme — the shipped theme.css a partial file inherits from
463
+ // ---------------------------------------------------------------------------
464
+ function listBases(ciaRoot) {
465
+ const dir = path.join(ciaRoot, 'public', 'themes');
466
+ try {
467
+ return fs.readdirSync(dir, { withFileTypes: true })
468
+ .filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, 'theme.css')))
469
+ .map((d) => d.name)
470
+ .sort();
471
+ } catch { return []; }
472
+ }
473
+
474
+ function loadBase(ciaRoot, base) {
475
+ const file = path.join(ciaRoot, 'public', 'themes', base, 'theme.css');
476
+ if (!fs.existsSync(file)) {
477
+ const bases = listBases(ciaRoot);
478
+ throw new Error(`unknown base theme "${base}". Available: ${bases.join(', ') || '(none found under public/themes)'}`);
479
+ }
480
+ const validator = require(path.join(ciaRoot, 'scripts', 'theme-validator.js'));
481
+ const text = fs.readFileSync(file, 'utf8');
482
+ const blocks = validator.extractDataThemeBlocks(text);
483
+ const values = blocks.length ? blocks[0].values : validator.extractRootBlock(text).values;
484
+ return { name: base, file, values }; // Map<token, rawValue>
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Paired-mode detection for a single Tokens Studio / DTCG file
489
+ // ---------------------------------------------------------------------------
490
+ const PAIRS = [['color-light', 'color-dark'], ['light', 'dark'], ['colors-light', 'colors-dark']];
491
+
492
+ function splitPairedModes(tokens) {
493
+ if (!isPlainObject(tokens)) return null;
494
+ for (const [l, d] of PAIRS) {
495
+ if (isPlainObject(tokens[l]) && isPlainObject(tokens[d])) {
496
+ const rest = {};
497
+ for (const [k, v] of Object.entries(tokens)) if (k !== l && k !== d) rest[k] = v;
498
+ const light = { ...rest, color: tokens[l] };
499
+ const dark = { ...rest, color: tokens[d] };
500
+ return { light, dark, groups: [l, d] };
501
+ }
502
+ }
503
+ return null;
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // One tokens object → Map<token, value> (+ bookkeeping)
508
+ // ---------------------------------------------------------------------------
509
+ function collect(tokens, format, contract) {
510
+ const declared = new Map(); // token → value
511
+ const unmapped = []; // { path, emittedAs }
512
+ const skipped = []; // { path, reason }
513
+ const fromPaths = new Map(); // token → path
514
+
515
+ if (format === 'cia-flat') {
516
+ for (const [k, v] of Object.entries(tokens)) {
517
+ if (!k.startsWith('--')) throw new Error(`cia-flat: key "${k}" is not a custom property`);
518
+ declared.set(k, String(v));
519
+ if (!contract.all.has(k)) unmapped.push({ path: k, emittedAs: k });
520
+ }
521
+ return { declared, unmapped, skipped, fromPaths };
522
+ }
523
+
524
+ let entries;
525
+ if (format === 'dtcg') entries = flattenDtcg(tokens);
526
+ else {
527
+ const { set } = mergeTokensStudioSets(tokens);
528
+ entries = flattenTokensStudio(set);
529
+ }
530
+ entries = resolveAliases(entries);
531
+
532
+ for (const e of entries) {
533
+ const { token, mapped } = mapPath(e.path, contract);
534
+ const norm = normalizeValue(e, token);
535
+ if (norm.skip) { skipped.push({ path: e.path, reason: norm.skip }); continue; }
536
+ declared.set(token, norm.value);
537
+ fromPaths.set(token, e.path);
538
+ if (!mapped) unmapped.push({ path: e.path, emittedAs: token });
539
+ }
540
+ return { declared, unmapped, skipped, fromPaths };
541
+ }
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // Public API
545
+ // ---------------------------------------------------------------------------
546
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
547
+
548
+ function themeFromTokens(opts = {}) {
549
+ const ciaRoot = opts.ciaRoot || CIA_ROOT_DEFAULT;
550
+ const name = String(opts.name || '').trim();
551
+ if (!NAME_RE.test(name)) throw new Error(`name must be a kebab-case slug (got "${opts.name}")`);
552
+ let tokens = typeof opts.tokens === 'string' ? JSON.parse(opts.tokens) : opts.tokens;
553
+ let dark = typeof opts.dark === 'string' ? JSON.parse(opts.dark) : opts.dark || null;
554
+ if (!isPlainObject(tokens)) throw new Error('tokens must be a JSON object (or a JSON string)');
555
+
556
+ const contract = loadContract(ciaRoot);
557
+ const base = loadBase(ciaRoot, opts.base || 'boilerplate');
558
+ const mode = opts.mode === 'dark' ? 'dark' : 'light';
559
+
560
+ let format = opts.format && opts.format !== 'auto' ? String(opts.format) : detectFormat(tokens);
561
+ if (!['dtcg', 'tokens-studio', 'cia-flat'].includes(format)) throw new Error(`format must be auto | dtcg | tokens-studio | cia-flat (got "${format}")`);
562
+
563
+ // A single file with paired light/dark groups is split automatically.
564
+ let pairedGroups = null;
565
+ if (!dark && format !== 'cia-flat') {
566
+ const pair = splitPairedModes(tokens);
567
+ if (pair) { tokens = pair.light; dark = pair.dark; pairedGroups = pair.groups; }
568
+ }
569
+ if (dark && !isPlainObject(dark)) throw new Error('dark must be a JSON object (or a JSON string)');
570
+ const darkFormat = dark ? (opts.format && opts.format !== 'auto' ? format : detectFormat(dark)) : null;
571
+
572
+ const light = collect(tokens, format, contract);
573
+ const darkSide = dark ? collect(dark, darkFormat, contract) : null;
574
+ const darkMode = Boolean(darkSide);
575
+
576
+ const isColorish = (v) => /^(#|rgb|hsl|hwb|lab|lch|oklab|oklch|color\(|light-dark\()/i.test(String(v).trim()) || /^[a-z]+$/i.test(String(v).trim());
577
+ const valueFor = (token) => {
578
+ const l = light.declared.get(token);
579
+ const d = darkSide ? darkSide.declared.get(token) : undefined;
580
+ if (l == null && d == null) return undefined;
581
+ if (d != null && l != null && d !== l && (isColorish(l) || isColorish(d))) return `light-dark(${l}, ${d})`;
582
+ if (l == null) return darkMode ? `light-dark(${d}, ${d})` : d;
583
+ return l;
584
+ };
585
+
586
+ const lines = [];
587
+ const fromTokens = [];
588
+ const inherited = [];
589
+ const optionalDeclared = [];
590
+
591
+ for (const token of contract.required) {
592
+ const v = valueFor(token);
593
+ if (v !== undefined) { lines.push([token, v]); fromTokens.push(token); continue; }
594
+ const b = base.values.get(token);
595
+ if (b === undefined) throw new Error(`base theme "${base.name}" does not declare required token ${token}; refusing to emit an incomplete theme`);
596
+ lines.push([token, b]);
597
+ inherited.push(token);
598
+ }
599
+ for (const token of contract.optional) {
600
+ const v = valueFor(token);
601
+ if (v !== undefined) { lines.push([token, v]); fromTokens.push(token); optionalDeclared.push(token); }
602
+ }
603
+ const seen = new Set(lines.map(([t]) => t));
604
+ const extras = [];
605
+ for (const [token, v] of light.declared) if (!seen.has(token)) { extras.push([token, valueFor(token)]); seen.add(token); }
606
+ if (darkSide) for (const [token] of darkSide.declared) if (!seen.has(token)) { extras.push([token, valueFor(token)]); seen.add(token); }
607
+ extras.sort((a, b) => a[0].localeCompare(b[0]));
608
+
609
+ const scheme = darkMode ? 'light dark' : mode;
610
+ const header =
611
+ `/* css-is-awesome theme "${name}" — generated by tokens-to-theme ${GENERATOR_VERSION}\n` +
612
+ ` source: ${format}${darkSide ? ` (+ dark side${pairedGroups ? `, paired groups ${pairedGroups.join('/')}` : ''})` : ''} · base: ${base.name} · contract ${contract.version}\n` +
613
+ ` ${fromTokens.length} token(s) from the design file, ${inherited.length} inherited from the base, ${extras.length} passed through verbatim */\n`;
614
+ const body =
615
+ `:root, :root[data-theme="${name}"] {\n` +
616
+ ` color-scheme: ${scheme};\n` +
617
+ [...lines, ...extras].map(([t, v]) => ` ${t}: ${v};`).join('\n') +
618
+ `\n}\n`;
619
+ const css = header + body;
620
+
621
+ const optionalMissing = contract.optional.filter((t) => !seen.has(t));
622
+ const report = {
623
+ name,
624
+ format,
625
+ base: base.name,
626
+ darkMode,
627
+ pairedGroups,
628
+ fromTokens,
629
+ inherited,
630
+ unmapped: [...light.unmapped, ...(darkSide ? darkSide.unmapped.filter((u) => !light.unmapped.some((x) => x.emittedAs === u.emittedAs)) : [])],
631
+ skipped: [...light.skipped, ...(darkSide ? darkSide.skipped : [])],
632
+ optionalDeclared,
633
+ optionalMissing,
634
+ counts: { fromTokens: fromTokens.length, inherited: inherited.length, unmapped: light.unmapped.length, skipped: light.skipped.length },
635
+ };
636
+
637
+ let validation = null;
638
+ if (opts.validate !== false) {
639
+ const validator = require(path.join(ciaRoot, 'scripts', 'theme-validator.js'));
640
+ validation = validator.validateText(css, validator.loadContract(), { label: name });
641
+ validation.a11ySummary = summarizeA11y(validation);
642
+ }
643
+ return { css, report, validation };
644
+ }
645
+
646
+ function summarizeA11y(validation) {
647
+ const audits = validation.mode === 'consolidated'
648
+ ? (validation.themes || []).flatMap((t) => t.a11y || [])
649
+ : validation.a11y || [];
650
+ const s = { fail: 0, warn: 0, pass: 0, info: 0, skip: 0 };
651
+ for (const r of audits) if (s[r.status] != null) s[r.status]++;
652
+ return s;
653
+ }
654
+
655
+ module.exports = {
656
+ themeFromTokens,
657
+ detectFormat,
658
+ flattenDtcg,
659
+ flattenTokensStudio,
660
+ mergeTokensStudioSets,
661
+ resolveAliases,
662
+ normalizeValue,
663
+ mapPath,
664
+ listBases,
665
+ TOKEN_MAP,
666
+ PATH_ALIASES,
667
+ GENERATOR_VERSION,
668
+ };