@caelo-cms/shared 0.10.22 → 0.10.23
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/dist/ai-tools.d.ts +290 -214
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +341 -264
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.1 (issue #76) — Tailwind 4 `@theme { … }` block importer.
|
|
5
|
+
*
|
|
6
|
+
* Tailwind 4 defines theme tokens in a `@theme` (or `@theme inline`) at-rule:
|
|
7
|
+
*
|
|
8
|
+
* @theme {
|
|
9
|
+
* --color-primary: #ff6600;
|
|
10
|
+
* --spacing-md: 1rem;
|
|
11
|
+
* --font-sans: Inter, system-ui;
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* The importer extracts each `--<namespace>-<rest>: <value>;` line from
|
|
15
|
+
* the brace pair and maps it onto a DTCG path via the inverse of
|
|
16
|
+
* `theme-render.ts`'s namespace table (`color.<rest>`, `spacing.<rest>`,
|
|
17
|
+
* etc.). The result is validated by `validateThemeTokens`.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately forgiving — handles `@theme inline`, single-line / block
|
|
20
|
+
* comments, blank lines. Hard-fails (TailwindImportError) on calc() /
|
|
21
|
+
* nested at-rules so the operator gets a concrete fix-it message
|
|
22
|
+
* instead of silently-truncated output.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { isUnsafeKey } from "../safe-keys.js";
|
|
26
|
+
import { type ThemeDocument, validateThemeTokens } from "../themes.js";
|
|
27
|
+
import { NotTailwindShape, TailwindImportError } from "../themes-errors.js";
|
|
28
|
+
import { stripCssComments } from "./css-comments.js";
|
|
29
|
+
|
|
30
|
+
interface CssVarEntry {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly value: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function importTailwind(body: string): ThemeDocument {
|
|
36
|
+
const block = extractAtThemeBlock(body);
|
|
37
|
+
if (block === null) {
|
|
38
|
+
throw new NotTailwindShape();
|
|
39
|
+
}
|
|
40
|
+
const entries = parseCssVarBlock(block);
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
throw new NotTailwindShape("@theme block found but no `--name: value;` pairs inside");
|
|
43
|
+
}
|
|
44
|
+
const doc = mapEntriesToDtcg(entries);
|
|
45
|
+
return validateThemeTokens(doc);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Locate the body of the first `@theme` (or `@theme inline`) at-rule.
|
|
50
|
+
* Returns the contents inside the matching `{ … }` (no braces, no
|
|
51
|
+
* outer whitespace) or null when no block is present.
|
|
52
|
+
*/
|
|
53
|
+
function extractAtThemeBlock(body: string): string | null {
|
|
54
|
+
const stripped = stripCssComments(body);
|
|
55
|
+
const match = /@theme\s+(?:inline\s+)?\{/i.exec(stripped);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
const start = match.index + match[0].length;
|
|
58
|
+
let depth = 1;
|
|
59
|
+
let i = start;
|
|
60
|
+
while (i < stripped.length && depth > 0) {
|
|
61
|
+
const ch = stripped[i];
|
|
62
|
+
if (ch === "{") depth += 1;
|
|
63
|
+
else if (ch === "}") depth -= 1;
|
|
64
|
+
if (depth === 0) break;
|
|
65
|
+
i += 1;
|
|
66
|
+
}
|
|
67
|
+
if (depth !== 0) return null;
|
|
68
|
+
return stripped.slice(start, i);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCssVarBlock(block: string): CssVarEntry[] {
|
|
72
|
+
const entries: CssVarEntry[] = [];
|
|
73
|
+
// Split on `;` so each entry is one declaration. Skip empty
|
|
74
|
+
// fragments and any line that doesn't start with `--`.
|
|
75
|
+
for (const raw of block.split(";")) {
|
|
76
|
+
const line = raw.trim();
|
|
77
|
+
if (line.length === 0) continue;
|
|
78
|
+
if (line.startsWith("//")) continue;
|
|
79
|
+
if (!line.startsWith("--")) {
|
|
80
|
+
// Catch nested at-rules; the importer doesn't handle them.
|
|
81
|
+
if (line.startsWith("@")) {
|
|
82
|
+
throw new TailwindImportError("nested @-rule", line);
|
|
83
|
+
}
|
|
84
|
+
// Tolerate plain identifiers (`color-scheme: dark` etc.) — skip silently.
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const colonIdx = line.indexOf(":");
|
|
88
|
+
if (colonIdx <= 0) continue;
|
|
89
|
+
const name = line.slice(0, colonIdx).trim();
|
|
90
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
91
|
+
if (value.length === 0) continue;
|
|
92
|
+
if (/\bcalc\s*\(/i.test(value)) {
|
|
93
|
+
throw new TailwindImportError("calc()", line);
|
|
94
|
+
}
|
|
95
|
+
if (/\bvar\s*\(/i.test(value)) {
|
|
96
|
+
throw new TailwindImportError("var()", line);
|
|
97
|
+
}
|
|
98
|
+
entries.push({ name, value });
|
|
99
|
+
}
|
|
100
|
+
return entries;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Map each `--<namespace>-<rest>` entry to a DTCG group. The namespace
|
|
105
|
+
* table mirrors theme-render.ts's emit table inverted.
|
|
106
|
+
*/
|
|
107
|
+
function mapEntriesToDtcg(entries: readonly CssVarEntry[]): ThemeDocument {
|
|
108
|
+
const out: Record<string, Record<string, unknown>> = {};
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
// Strip the leading `--` and split on the first hyphen.
|
|
111
|
+
const stripped = entry.name.slice(2);
|
|
112
|
+
const firstHyphen = stripped.indexOf("-");
|
|
113
|
+
if (firstHyphen <= 0) continue; // no category prefix → skip
|
|
114
|
+
const ns = stripped.slice(0, firstHyphen);
|
|
115
|
+
const rest = stripped.slice(firstHyphen + 1);
|
|
116
|
+
|
|
117
|
+
let category: string;
|
|
118
|
+
let leafName: string;
|
|
119
|
+
let extraSubField: string | null = null;
|
|
120
|
+
switch (ns) {
|
|
121
|
+
case "color":
|
|
122
|
+
category = "color";
|
|
123
|
+
leafName = rest;
|
|
124
|
+
break;
|
|
125
|
+
case "spacing":
|
|
126
|
+
category = "spacing";
|
|
127
|
+
leafName = rest;
|
|
128
|
+
break;
|
|
129
|
+
case "radius":
|
|
130
|
+
category = "radius";
|
|
131
|
+
leafName = rest;
|
|
132
|
+
break;
|
|
133
|
+
case "shadow":
|
|
134
|
+
category = "shadow";
|
|
135
|
+
leafName = rest;
|
|
136
|
+
break;
|
|
137
|
+
case "breakpoint":
|
|
138
|
+
category = "breakpoint";
|
|
139
|
+
leafName = rest;
|
|
140
|
+
break;
|
|
141
|
+
case "duration":
|
|
142
|
+
category = "duration";
|
|
143
|
+
leafName = rest;
|
|
144
|
+
break;
|
|
145
|
+
case "ease":
|
|
146
|
+
category = "ease";
|
|
147
|
+
leafName = rest;
|
|
148
|
+
break;
|
|
149
|
+
case "font":
|
|
150
|
+
// --font-sans → typography.sans.fontFamily
|
|
151
|
+
category = "typography";
|
|
152
|
+
leafName = rest;
|
|
153
|
+
extraSubField = "fontFamily";
|
|
154
|
+
break;
|
|
155
|
+
case "text":
|
|
156
|
+
// --text-base → typography.base.fontSize
|
|
157
|
+
category = "typography";
|
|
158
|
+
leafName = rest;
|
|
159
|
+
extraSubField = "fontSize";
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
// Unknown namespace — bucket under its own root so the renderer
|
|
163
|
+
// still emits the var (forward-compat).
|
|
164
|
+
category = ns;
|
|
165
|
+
leafName = rest;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `category` and `leafName` are derived from the imported CSS variable
|
|
169
|
+
// name, so a hostile token like `--__proto__-x` could otherwise pollute
|
|
170
|
+
// the prototype chain via `out[category]` / `group[leafName]` (CodeQL
|
|
171
|
+
// js/prototype-polluting-assignment). Skip the entry on any unsafe key.
|
|
172
|
+
if (isUnsafeKey(category) || isUnsafeKey(leafName)) continue;
|
|
173
|
+
|
|
174
|
+
if (!out[category]) out[category] = {};
|
|
175
|
+
const group = out[category] ?? {};
|
|
176
|
+
if (extraSubField !== null) {
|
|
177
|
+
// Typography composite: merge into the existing leaf's $value.
|
|
178
|
+
const existing = group[leafName];
|
|
179
|
+
const baseObj =
|
|
180
|
+
existing && typeof existing === "object" && "$value" in existing
|
|
181
|
+
? ((existing as { $value: unknown }).$value as Record<string, unknown>)
|
|
182
|
+
: {};
|
|
183
|
+
group[leafName] = {
|
|
184
|
+
$value: { ...baseObj, [extraSubField]: entry.value },
|
|
185
|
+
$type: "typography",
|
|
186
|
+
};
|
|
187
|
+
} else {
|
|
188
|
+
// v0.11.1 (issue #76 Copilot review #4): if `leafName` looks like
|
|
189
|
+
// a Tailwind ramp suffix (`primary-50` … `primary-900`,
|
|
190
|
+
// `primary-DEFAULT`), nest it as `<group>.<stop>` so the result
|
|
191
|
+
// matches the OKLCh ramp's DTCG group shape. Without this,
|
|
192
|
+
// `--color-primary-50` would land at the flat key `color['primary-50']`
|
|
193
|
+
// and downstream ramp-aware logic (DEFAULT-alias resolver, the
|
|
194
|
+
// ColorEditor's color.primary swatch, the propose-create explicit-
|
|
195
|
+
// stop override path) wouldn't see the stops as a group.
|
|
196
|
+
const rampMatch = /^(.+)-(50|100|200|300|400|500|600|700|800|900|DEFAULT)$/.exec(leafName);
|
|
197
|
+
const tokenLeaf = {
|
|
198
|
+
$value: entry.value,
|
|
199
|
+
$type: category === "color" ? "color" : category === "duration" ? "duration" : "dimension",
|
|
200
|
+
};
|
|
201
|
+
if (rampMatch && category === "color") {
|
|
202
|
+
const [, baseName, stop] = rampMatch as unknown as [string, string, string];
|
|
203
|
+
// `baseName` is the ramp prefix parsed from the token name; guard it
|
|
204
|
+
// before it becomes a `group[baseName]` assignment target. `stop` is
|
|
205
|
+
// a fixed enum (50…900|DEFAULT), so it needs no guard.
|
|
206
|
+
if (isUnsafeKey(baseName)) continue;
|
|
207
|
+
if (!group[baseName] || typeof group[baseName] !== "object") {
|
|
208
|
+
group[baseName] = {};
|
|
209
|
+
}
|
|
210
|
+
(group[baseName] as Record<string, unknown>)[stop] = tokenLeaf;
|
|
211
|
+
} else {
|
|
212
|
+
group[leafName] = tokenLeaf;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out as ThemeDocument;
|
|
217
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #164 slice 2 — mechanical token binding: value-map parsing from
|
|
5
|
+
* renderer output (aliases + ramps included by construction), color +
|
|
6
|
+
* whole-gradient rewrites, stop-color protection inside unmatched
|
|
7
|
+
* gradients, dimension suggestions (never auto-rewritten), reporting.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
applyThemeLiteralBinding,
|
|
13
|
+
buildThemeValueMap,
|
|
14
|
+
formatBindingReport,
|
|
15
|
+
} from "./theme-literal-binding.js";
|
|
16
|
+
import type { ThemeDocument } from "./themes.js";
|
|
17
|
+
|
|
18
|
+
const TOKENS = {
|
|
19
|
+
color: {
|
|
20
|
+
primary: { $type: "color", $value: "#4F46E5" },
|
|
21
|
+
foreground: { $type: "color", $value: "#0f172a" },
|
|
22
|
+
accent: { $type: "color", $value: "{color.primary}" },
|
|
23
|
+
},
|
|
24
|
+
gradient: {
|
|
25
|
+
hero: { $type: "gradient", $value: "linear-gradient(135deg, #4f46e5, #7c3aed)" },
|
|
26
|
+
},
|
|
27
|
+
spacing: { xl: { $type: "dimension", $value: "6rem" } },
|
|
28
|
+
} as unknown as ThemeDocument;
|
|
29
|
+
|
|
30
|
+
describe("buildThemeValueMap", () => {
|
|
31
|
+
it("maps normalized renderer-emitted values to var names (aliases resolved)", () => {
|
|
32
|
+
const map = buildThemeValueMap(TOKENS);
|
|
33
|
+
expect(map.colors.get("#4f46e5")).toBe("--color-primary"); // first-declared wins over alias
|
|
34
|
+
expect(map.colors.get("#0f172a")).toBe("--color-foreground");
|
|
35
|
+
expect(map.gradients.get("linear-gradient(135deg, #4f46e5, #7c3aed)")).toBe("--gradient-hero");
|
|
36
|
+
expect(map.dimensions.get("6rem")).toBe("--spacing-xl");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("applyThemeLiteralBinding", () => {
|
|
41
|
+
it("binds whole gradients before stop colors, and matching colors elsewhere", () => {
|
|
42
|
+
const css =
|
|
43
|
+
".hero{background:linear-gradient(135deg, #4F46E5, #7c3aed)}.cta{background:#4f46e5;color:#fff}";
|
|
44
|
+
const r = applyThemeLiteralBinding(css, TOKENS);
|
|
45
|
+
expect(r.css).toContain("background:var(--gradient-hero)");
|
|
46
|
+
expect(r.css).toContain("background:var(--color-primary)");
|
|
47
|
+
expect(r.css).toContain("color:#fff"); // unknown literal stays
|
|
48
|
+
expect(r.rewrites.map((x) => x.to).sort()).toEqual(["--color-primary", "--gradient-hero"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves stop colors inside UNMATCHED gradients literal (no half-tokenising)", () => {
|
|
52
|
+
const css = ".x{background:linear-gradient(90deg, #4f46e5, #000000)}";
|
|
53
|
+
const r = applyThemeLiteralBinding(css, TOKENS);
|
|
54
|
+
expect(r.css).toBe(css);
|
|
55
|
+
expect(r.rewrites).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("suggests dimension tokens without rewriting", () => {
|
|
59
|
+
const css = ".s{padding:6rem 2rem}";
|
|
60
|
+
const r = applyThemeLiteralBinding(css, TOKENS);
|
|
61
|
+
expect(r.css).toBe(css);
|
|
62
|
+
expect(r.suggestions).toEqual([{ literal: "6rem", varName: "--spacing-xl" }]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("formats a report and stays silent on no-ops", () => {
|
|
66
|
+
const bound = applyThemeLiteralBinding(".a{color:#0F172A}", TOKENS);
|
|
67
|
+
expect(formatBindingReport(bound)).toContain("#0F172A→var(--color-foreground)");
|
|
68
|
+
const noop = applyThemeLiteralBinding(".a{color:#123456}", TOKENS);
|
|
69
|
+
expect(formatBindingReport(noop)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* issue #164 slice 2 — mechanical token binding ("AI decides, code
|
|
5
|
+
* executes"). During Genesis materialisation the theme is created FROM
|
|
6
|
+
* the draft's palette, so module CSS copied from the draft contains
|
|
7
|
+
* literals that EQUAL theme token values. Hand-translating them to
|
|
8
|
+
* `var(--…)` is transcription work the model can get subtly wrong;
|
|
9
|
+
* this module does it mechanically:
|
|
10
|
+
*
|
|
11
|
+
* - colors + gradients whose literal value equals an emitted token's
|
|
12
|
+
* value are rewritten to `var(--name)` (unambiguous: the value IS
|
|
13
|
+
* the token's value);
|
|
14
|
+
* - dimension-ish matches (a `6rem` that equals `--spacing-2xl`) are
|
|
15
|
+
* reported as SUGGESTIONS only — generic lengths are too ambiguous
|
|
16
|
+
* to rewrite silently.
|
|
17
|
+
*
|
|
18
|
+
* The value map is parsed from `renderThemeCss` output so binding uses
|
|
19
|
+
* exactly what the renderer emits (aliases resolved, ramps included) —
|
|
20
|
+
* no second resolution path to drift.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { replaceCssGradients } from "./css-gradient-scan.js";
|
|
24
|
+
import { renderThemeCss } from "./theme-render.js";
|
|
25
|
+
import type { ThemeDocument } from "./themes.js";
|
|
26
|
+
|
|
27
|
+
const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:oklch|rgba?|hsla?|lab|lch|hwb)\([^()]*\)/g;
|
|
28
|
+
const DIMENSION_VALUE_RE = /^-?\d+(\.\d+)?(rem|em|px|%)$/;
|
|
29
|
+
|
|
30
|
+
export interface ThemeValueMap {
|
|
31
|
+
/** normalized color value → var name (first-declared wins). */
|
|
32
|
+
readonly colors: ReadonlyMap<string, string>;
|
|
33
|
+
/** exact gradient string (normalized whitespace) → var name. */
|
|
34
|
+
readonly gradients: ReadonlyMap<string, string>;
|
|
35
|
+
/** dimension value → var name (suggestion-only tier). */
|
|
36
|
+
readonly dimensions: ReadonlyMap<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeColor(v: string): string {
|
|
40
|
+
return v.trim().toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
function normalizeGradient(v: string): string {
|
|
43
|
+
return v.trim().toLowerCase().replace(/\s+/g, " ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse the light-branch declarations the renderer emits for this
|
|
48
|
+
* document into value → var-name maps.
|
|
49
|
+
*/
|
|
50
|
+
export function buildThemeValueMap(tokens: ThemeDocument): ThemeValueMap {
|
|
51
|
+
const css = renderThemeCss(tokens);
|
|
52
|
+
// Only the light branch: `.dark` overrides carry the same names.
|
|
53
|
+
const lightEnd = css.indexOf(".dark{");
|
|
54
|
+
const light = lightEnd === -1 ? css : css.slice(0, lightEnd);
|
|
55
|
+
|
|
56
|
+
const colors = new Map<string, string>();
|
|
57
|
+
const gradients = new Map<string, string>();
|
|
58
|
+
const dimensions = new Map<string, string>();
|
|
59
|
+
// Regex-free declaration split (a `(--name):(value);` regex is the
|
|
60
|
+
// #113 polynomial shape on adversarial dash runs). The light block
|
|
61
|
+
// is `:root{--a:v;--b:v;}` — split on `;`, take name:value pairs.
|
|
62
|
+
for (const rawSeg of light.split(";")) {
|
|
63
|
+
const seg = rawSeg.slice(rawSeg.lastIndexOf("{") + 1);
|
|
64
|
+
const colon = seg.indexOf(":");
|
|
65
|
+
if (colon <= 0) continue;
|
|
66
|
+
const name = seg.slice(0, colon).trim();
|
|
67
|
+
const value = seg.slice(colon + 1).trim();
|
|
68
|
+
if (!name.startsWith("--") || value.length === 0) continue;
|
|
69
|
+
if (name.startsWith("--gradient-")) {
|
|
70
|
+
const key = normalizeGradient(value);
|
|
71
|
+
if (!gradients.has(key)) gradients.set(key, name);
|
|
72
|
+
} else if (name.startsWith("--color-")) {
|
|
73
|
+
const key = normalizeColor(value);
|
|
74
|
+
if (!colors.has(key)) colors.set(key, name);
|
|
75
|
+
} else if (DIMENSION_VALUE_RE.test(value)) {
|
|
76
|
+
if (!dimensions.has(value)) dimensions.set(value, name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { colors, gradients, dimensions };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface LiteralBindingResult {
|
|
83
|
+
readonly css: string;
|
|
84
|
+
/** Applied rewrites: literal → var name, with occurrence counts. */
|
|
85
|
+
readonly rewrites: readonly { from: string; to: string; count: number }[];
|
|
86
|
+
/** Dimension matches left in place (too ambiguous to auto-rewrite). */
|
|
87
|
+
readonly suggestions: readonly { literal: string; varName: string }[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rewrite color/gradient literals that equal theme token values to
|
|
92
|
+
* `var(--name)`. Longest-first (gradients before their stop colors) so
|
|
93
|
+
* a gradient binds as a whole instead of stop-by-stop.
|
|
94
|
+
*/
|
|
95
|
+
export function applyThemeLiteralBinding(css: string, tokens: ThemeDocument): LiteralBindingResult {
|
|
96
|
+
const map = buildThemeValueMap(tokens);
|
|
97
|
+
const counts = new Map<string, { to: string; count: number }>();
|
|
98
|
+
let out = css;
|
|
99
|
+
|
|
100
|
+
out = replaceCssGradients(out, (literal) => {
|
|
101
|
+
const varName = map.gradients.get(normalizeGradient(literal));
|
|
102
|
+
if (varName === undefined) return literal;
|
|
103
|
+
const entry = counts.get(literal) ?? { to: varName, count: 0 };
|
|
104
|
+
entry.count += 1;
|
|
105
|
+
counts.set(literal, entry);
|
|
106
|
+
return `var(${varName})`;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
out = out.replace(COLOR_LITERAL_RE, (literal, offset: number) => {
|
|
110
|
+
// Never rewrite inside an already-produced var(...) fallback or a
|
|
111
|
+
// remaining gradient (a gradient that did NOT match stays literal
|
|
112
|
+
// by decision — rebinding its stops would half-tokenise it).
|
|
113
|
+
const before = out.slice(Math.max(0, offset - 64), offset);
|
|
114
|
+
if (/gradient\([^()]*$/i.test(before)) return literal;
|
|
115
|
+
const varName = map.colors.get(normalizeColor(literal));
|
|
116
|
+
if (varName === undefined) return literal;
|
|
117
|
+
const entry = counts.get(literal) ?? { to: varName, count: 0 };
|
|
118
|
+
entry.count += 1;
|
|
119
|
+
counts.set(literal, entry);
|
|
120
|
+
return `var(${varName})`;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const suggestions: { literal: string; varName: string }[] = [];
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
for (const m of out.matchAll(/-?\d+(?:\.\d+)?(?:rem|em|px|%)\b/g)) {
|
|
126
|
+
const literal = m[0];
|
|
127
|
+
if (seen.has(literal)) continue;
|
|
128
|
+
seen.add(literal);
|
|
129
|
+
const varName = map.dimensions.get(literal);
|
|
130
|
+
if (varName !== undefined) suggestions.push({ literal, varName });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
css: out,
|
|
135
|
+
rewrites: [...counts.entries()].map(([from, e]) => ({ from, to: e.to, count: e.count })),
|
|
136
|
+
suggestions,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Tool-result suffix describing what binding did. Null on no-op. */
|
|
141
|
+
export function formatBindingReport(result: LiteralBindingResult): string | null {
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (result.rewrites.length > 0) {
|
|
144
|
+
const total = result.rewrites.reduce((n, r) => n + r.count, 0);
|
|
145
|
+
parts.push(
|
|
146
|
+
`🔗 bound ${total} literal(s) to theme vars: ${result.rewrites
|
|
147
|
+
.map((r) => `${r.from}→var(${r.to})${r.count > 1 ? `×${r.count}` : ""}`)
|
|
148
|
+
.join(", ")}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (result.suggestions.length > 0) {
|
|
152
|
+
parts.push(
|
|
153
|
+
`consider tokens for: ${result.suggestions
|
|
154
|
+
.map((s) => `${s.literal}≈var(${s.varName})`)
|
|
155
|
+
.join(", ")} (dimensions are never auto-rewritten)`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return parts.length > 0 ? ` ${parts.join(". ")}.` : null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A live build turn failed on `set_theme_tokens` because the `motion`
|
|
5
|
+
* token accepted only a DTCG duration, a `cubicBezier` [x1,y1,x2,y2]
|
|
6
|
+
* tuple, or a dimension — NOT the CSS easing forms a model naturally
|
|
7
|
+
* emits for `motion.easing` (a keyword like `ease-in-out`, or a literal
|
|
8
|
+
* `cubic-bezier(0.4, 0, 0.2, 1)` string). The rejection message was a
|
|
9
|
+
* bare "motion token invalid: Invalid input" that told the model
|
|
10
|
+
* nothing about the accepted shape (CLAUDE.md §11 — AI-actionable
|
|
11
|
+
* errors). This pins the loosened validation + the actionable message.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "bun:test";
|
|
15
|
+
import { themeDocument } from "./themes.js";
|
|
16
|
+
|
|
17
|
+
function docWithMotion(name: string, value: unknown): unknown {
|
|
18
|
+
return {
|
|
19
|
+
color: { primary: { $type: "color", $value: "#4f46e5" } },
|
|
20
|
+
motion: { [name]: { $value: value } },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("motion.easing accepts the common CSS easing forms", () => {
|
|
25
|
+
it("accepts every CSS easing keyword", () => {
|
|
26
|
+
for (const kw of [
|
|
27
|
+
"linear",
|
|
28
|
+
"ease",
|
|
29
|
+
"ease-in",
|
|
30
|
+
"ease-out",
|
|
31
|
+
"ease-in-out",
|
|
32
|
+
"step-start",
|
|
33
|
+
"step-end",
|
|
34
|
+
]) {
|
|
35
|
+
const r = themeDocument.safeParse(docWithMotion("easing", kw));
|
|
36
|
+
expect(r.success).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("accepts a literal cubic-bezier(...) string", () => {
|
|
41
|
+
for (const v of ["cubic-bezier(0.4, 0, 0.2, 1)", "cubic-bezier(.25,.1,.25,1)"]) {
|
|
42
|
+
expect(themeDocument.safeParse(docWithMotion("easing", v)).success).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("accepts a steps(...) timing function", () => {
|
|
47
|
+
expect(themeDocument.safeParse(docWithMotion("easing", "steps(4, end)")).success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("still accepts the DTCG cubicBezier tuple, a duration, and an alias", () => {
|
|
51
|
+
expect(themeDocument.safeParse(docWithMotion("ease", [0.4, 0, 0.2, 1])).success).toBe(true);
|
|
52
|
+
expect(themeDocument.safeParse(docWithMotion("fast", "200ms")).success).toBe(true);
|
|
53
|
+
expect(themeDocument.safeParse(docWithMotion("smooth", "{motion.ease}")).success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("motion tokens reject junk loudly with an actionable message", () => {
|
|
58
|
+
it("rejects a number, a boolean, and an empty string", () => {
|
|
59
|
+
for (const v of [42, true, ""]) {
|
|
60
|
+
expect(themeDocument.safeParse(docWithMotion("easing", v)).success).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects a bogus keyword", () => {
|
|
65
|
+
expect(themeDocument.safeParse(docWithMotion("easing", "wobble")).success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("the rejection message names the token, the accepted shapes, and the value", () => {
|
|
69
|
+
const r = themeDocument.safeParse(docWithMotion("easing", 42));
|
|
70
|
+
expect(r.success).toBe(false);
|
|
71
|
+
if (r.success) return;
|
|
72
|
+
const msg = r.error.issues[0]!.message;
|
|
73
|
+
expect(msg).toContain('motion token "easing" invalid');
|
|
74
|
+
expect(msg).toContain('expected a duration (e.g. "200ms")');
|
|
75
|
+
expect(msg).toContain("cubic-bezier(...)");
|
|
76
|
+
expect(msg).toContain("ease-in-out");
|
|
77
|
+
expect(msg).toContain("got 42");
|
|
78
|
+
// The old vague text must be gone.
|
|
79
|
+
expect(msg).not.toContain("Invalid input");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 2026-07-28 — a model wrote `motion: "180ms ease"`, the CSS transition
|
|
85
|
+
* shorthand. Listing the accepted forms did not tell it what was actually
|
|
86
|
+
* wrong (two tokens in one string), so it took three attempts to correct —
|
|
87
|
+
* and, because gated proposals were validated only AFTER approval, three
|
|
88
|
+
* operator clicks. The message now names the mistake.
|
|
89
|
+
*/
|
|
90
|
+
describe("a CSS shorthand names the real mistake, not just the legal forms", () => {
|
|
91
|
+
it("tells the caller to split duration and easing", () => {
|
|
92
|
+
const r = themeDocument.safeParse(docWithMotion("motion", "180ms ease"));
|
|
93
|
+
expect(r.success).toBe(false);
|
|
94
|
+
if (r.success) return;
|
|
95
|
+
const msg = r.error.issues[0]!.message;
|
|
96
|
+
expect(msg).toContain("CSS transition shorthand");
|
|
97
|
+
expect(msg).toContain('put "180ms" in a duration token');
|
|
98
|
+
expect(msg).toContain('"ease" in an easing token');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("covers the seconds form too", () => {
|
|
102
|
+
const r = themeDocument.safeParse(docWithMotion("motion", "0.2s ease-in-out"));
|
|
103
|
+
expect(r.success).toBe(false);
|
|
104
|
+
if (r.success) return;
|
|
105
|
+
expect(r.error.issues[0]!.message).toContain('put "0.2s" in a duration token');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("stays silent when the value is not a shorthand", () => {
|
|
109
|
+
// A plain bad value must not get a shorthand lecture it cannot act on.
|
|
110
|
+
const r = themeDocument.safeParse(docWithMotion("motion", "banana"));
|
|
111
|
+
expect(r.success).toBe(false);
|
|
112
|
+
if (r.success) return;
|
|
113
|
+
expect(r.error.issues[0]!.message).not.toContain("shorthand");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* issue #242 (F11) — normalizeTokens accepts BOTH the DTCG envelope
|
|
4
|
+
* (`{$type, $value}`) and the raw `$value` form for composite values,
|
|
5
|
+
* plus JSON-encoded object strings. Garbage still fails downstream
|
|
6
|
+
* validation — this layer only repairs encoding, never content.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import { normalizeTokens } from "./theme-normalize.js";
|
|
10
|
+
|
|
11
|
+
describe("normalizeTokens envelope tolerance (#242)", () => {
|
|
12
|
+
const typographyValue = { fontFamily: "Inter, sans-serif", fontSize: "1rem", fontWeight: 600 };
|
|
13
|
+
|
|
14
|
+
it("accepts the raw composite $value object", () => {
|
|
15
|
+
const r = normalizeTokens({ "typography.body": typographyValue });
|
|
16
|
+
expect(r.set["typography.body"]).toEqual(typographyValue);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("unwraps the full DTCG envelope to its $value", () => {
|
|
20
|
+
const r = normalizeTokens({
|
|
21
|
+
"typography.body": { $type: "typography", $value: typographyValue },
|
|
22
|
+
});
|
|
23
|
+
expect(r.set["typography.body"]).toEqual(typographyValue);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses a JSON-encoded composite string, then unwraps", () => {
|
|
27
|
+
const r = normalizeTokens({
|
|
28
|
+
"typography.body": JSON.stringify({ $type: "typography", $value: typographyValue }),
|
|
29
|
+
});
|
|
30
|
+
expect(r.set["typography.body"]).toEqual(typographyValue);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps plain scalar values untouched", () => {
|
|
34
|
+
const r = normalizeTokens({ "color.primary": "#7c2d12" });
|
|
35
|
+
expect(r.set["color.primary"]).toBe("#7c2d12");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("still rejects garbage loudly — encoding repair never validates content", () => {
|
|
39
|
+
expect(() => normalizeTokens({ "color.primary": "{not json" })).toThrow(
|
|
40
|
+
/not a recognised CSS color/,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|