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 +10 -5
- package/CHANGELOG.md +8 -0
- package/README.md +7 -3
- package/bin/cia.cjs +19 -0
- package/bin/theme-from-tokens.cjs +139 -0
- package/dist/tokens.d.ts +1 -1
- package/figma-tokens/README.md +15 -0
- package/llm.txt +2 -2
- package/mcp/server.cjs +35 -1
- package/package.json +3 -1
- package/scripts/theme-validator.js +4 -0
- package/scripts/tokens-to-theme.cjs +668 -0
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 **
|
|
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.
|
|
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
|
|
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** —
|
|
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 —
|
|
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 **
|
|
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
|
|
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
package/figma-tokens/README.md
CHANGED
|
@@ -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`. **
|
|
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
|
-
*
|
|
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.
|
|
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
|
+
};
|