@caelo-cms/shared 0.10.22 → 0.10.24

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.
Files changed (249) hide show
  1. package/dist/ai-tools.d.ts +289 -273
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +342 -323
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +35 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +40 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-draft-shell.d.ts +21 -0
  29. package/dist/design-draft-shell.d.ts.map +1 -0
  30. package/dist/design-draft-shell.js +81 -0
  31. package/dist/design-draft-shell.js.map +1 -0
  32. package/dist/design-manifest.d.ts +36 -0
  33. package/dist/design-manifest.d.ts.map +1 -0
  34. package/dist/design-manifest.js +90 -0
  35. package/dist/design-manifest.js.map +1 -0
  36. package/dist/fonts.d.ts +89 -0
  37. package/dist/fonts.d.ts.map +1 -0
  38. package/dist/fonts.js +241 -0
  39. package/dist/fonts.js.map +1 -0
  40. package/dist/genesis-inventory.d.ts +32 -0
  41. package/dist/genesis-inventory.d.ts.map +1 -0
  42. package/dist/genesis-inventory.js +186 -0
  43. package/dist/genesis-inventory.js.map +1 -0
  44. package/dist/genesis.d.ts +102 -0
  45. package/dist/genesis.d.ts.map +1 -0
  46. package/dist/genesis.js +145 -0
  47. package/dist/genesis.js.map +1 -0
  48. package/dist/i18n.d.ts +29 -36
  49. package/dist/i18n.d.ts.map +1 -1
  50. package/dist/i18n.js +53 -128
  51. package/dist/i18n.js.map +1 -1
  52. package/dist/index.d.ts +28 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/interactions.d.ts +23 -0
  57. package/dist/interactions.d.ts.map +1 -0
  58. package/dist/interactions.js +44 -0
  59. package/dist/interactions.js.map +1 -0
  60. package/dist/media.d.ts +101 -16
  61. package/dist/media.d.ts.map +1 -1
  62. package/dist/media.js +126 -15
  63. package/dist/media.js.map +1 -1
  64. package/dist/page-log.d.ts +94 -0
  65. package/dist/page-log.d.ts.map +1 -0
  66. package/dist/page-log.js +111 -0
  67. package/dist/page-log.js.map +1 -0
  68. package/dist/preview-compose.d.ts +85 -12
  69. package/dist/preview-compose.d.ts.map +1 -1
  70. package/dist/preview-compose.js +157 -67
  71. package/dist/preview-compose.js.map +1 -1
  72. package/dist/proposal-status.d.ts +40 -0
  73. package/dist/proposal-status.d.ts.map +1 -0
  74. package/dist/proposal-status.js +34 -0
  75. package/dist/proposal-status.js.map +1 -0
  76. package/dist/responsive-images.d.ts +64 -0
  77. package/dist/responsive-images.d.ts.map +1 -0
  78. package/dist/responsive-images.js +98 -0
  79. package/dist/responsive-images.js.map +1 -0
  80. package/dist/safe-keys.d.ts +9 -0
  81. package/dist/safe-keys.d.ts.map +1 -0
  82. package/dist/safe-keys.js +20 -0
  83. package/dist/safe-keys.js.map +1 -0
  84. package/dist/seo.d.ts +11 -16
  85. package/dist/seo.d.ts.map +1 -1
  86. package/dist/seo.js +7 -23
  87. package/dist/seo.js.map +1 -1
  88. package/dist/skills.d.ts +14 -68
  89. package/dist/skills.d.ts.map +1 -1
  90. package/dist/skills.js +19 -113
  91. package/dist/skills.js.map +1 -1
  92. package/dist/strip-cdata.d.ts +7 -0
  93. package/dist/strip-cdata.d.ts.map +1 -0
  94. package/dist/strip-cdata.js +48 -0
  95. package/dist/strip-cdata.js.map +1 -0
  96. package/dist/structured-sets.d.ts +6 -52
  97. package/dist/structured-sets.d.ts.map +1 -1
  98. package/dist/structured-sets.js +6 -68
  99. package/dist/structured-sets.js.map +1 -1
  100. package/dist/subagents.d.ts +105 -3
  101. package/dist/subagents.d.ts.map +1 -1
  102. package/dist/subagents.js +224 -41
  103. package/dist/subagents.js.map +1 -1
  104. package/dist/template-engine.d.ts +85 -0
  105. package/dist/template-engine.d.ts.map +1 -0
  106. package/dist/template-engine.js +403 -0
  107. package/dist/template-engine.js.map +1 -0
  108. package/dist/theme-importers/auto-detect.d.ts +26 -0
  109. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  110. package/dist/theme-importers/auto-detect.js +42 -0
  111. package/dist/theme-importers/auto-detect.js.map +1 -0
  112. package/dist/theme-importers/css-comments.d.ts +12 -0
  113. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  114. package/dist/theme-importers/css-comments.js +15 -0
  115. package/dist/theme-importers/css-comments.js.map +1 -0
  116. package/dist/theme-importers/dtcg.d.ts +46 -0
  117. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  118. package/dist/theme-importers/dtcg.js +111 -0
  119. package/dist/theme-importers/dtcg.js.map +1 -0
  120. package/dist/theme-importers/loose.d.ts +3 -0
  121. package/dist/theme-importers/loose.d.ts.map +1 -0
  122. package/dist/theme-importers/loose.js +76 -0
  123. package/dist/theme-importers/loose.js.map +1 -0
  124. package/dist/theme-importers/shadcn.d.ts +24 -0
  125. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  126. package/dist/theme-importers/shadcn.js +135 -0
  127. package/dist/theme-importers/shadcn.js.map +1 -0
  128. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  129. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  130. package/dist/theme-importers/style-dictionary.js +125 -0
  131. package/dist/theme-importers/style-dictionary.js.map +1 -0
  132. package/dist/theme-importers/tailwind.d.ts +3 -0
  133. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  134. package/dist/theme-importers/tailwind.js +218 -0
  135. package/dist/theme-importers/tailwind.js.map +1 -0
  136. package/dist/theme-literal-binding.d.ts +37 -0
  137. package/dist/theme-literal-binding.d.ts.map +1 -0
  138. package/dist/theme-literal-binding.js +138 -0
  139. package/dist/theme-literal-binding.js.map +1 -0
  140. package/dist/theme-normalize.d.ts +31 -0
  141. package/dist/theme-normalize.d.ts.map +1 -0
  142. package/dist/theme-normalize.js +587 -0
  143. package/dist/theme-normalize.js.map +1 -0
  144. package/dist/theme-ramp.d.ts +55 -0
  145. package/dist/theme-ramp.d.ts.map +1 -0
  146. package/dist/theme-ramp.js +149 -0
  147. package/dist/theme-ramp.js.map +1 -0
  148. package/dist/theme-render.d.ts +105 -0
  149. package/dist/theme-render.d.ts.map +1 -0
  150. package/dist/theme-render.js +441 -0
  151. package/dist/theme-render.js.map +1 -0
  152. package/dist/themes-errors.d.ts +109 -0
  153. package/dist/themes-errors.d.ts.map +1 -0
  154. package/dist/themes-errors.js +170 -0
  155. package/dist/themes-errors.js.map +1 -0
  156. package/dist/themes.d.ts +343 -0
  157. package/dist/themes.d.ts.map +1 -0
  158. package/dist/themes.js +697 -0
  159. package/dist/themes.js.map +1 -0
  160. package/dist/version.d.ts +7 -4
  161. package/dist/version.d.ts.map +1 -1
  162. package/dist/version.js +6 -3
  163. package/dist/version.js.map +1 -1
  164. package/package.json +10 -2
  165. package/src/__tests__/redos-hardening.test.ts +160 -0
  166. package/src/ai-tools-add-module-modes.test.ts +106 -0
  167. package/src/ai-tools-position.test.ts +134 -0
  168. package/src/ai-tools.test.ts +81 -0
  169. package/src/ai-tools.ts +1105 -0
  170. package/src/auth-forms.ts +36 -0
  171. package/src/base-css.ts +42 -0
  172. package/src/build-page.test.ts +228 -0
  173. package/src/build-page.ts +319 -0
  174. package/src/cap-failures.ts +67 -0
  175. package/src/content.test.ts +170 -0
  176. package/src/content.ts +620 -0
  177. package/src/context.ts +43 -0
  178. package/src/css-gradient-scan.ts +88 -0
  179. package/src/css-var-scan.test.ts +96 -0
  180. package/src/css-var-scan.ts +144 -0
  181. package/src/derive-module-type.test.ts +80 -0
  182. package/src/design-draft-shell.test.ts +85 -0
  183. package/src/design-draft-shell.ts +109 -0
  184. package/src/design-manifest.ts +93 -0
  185. package/src/fonts.test.ts +157 -0
  186. package/src/fonts.ts +296 -0
  187. package/src/genesis-inventory.test.ts +86 -0
  188. package/src/genesis-inventory.ts +215 -0
  189. package/src/genesis-sanitize.test.ts +35 -0
  190. package/src/genesis.ts +158 -0
  191. package/src/i18n.test.ts +58 -0
  192. package/src/i18n.ts +91 -0
  193. package/src/index.test.ts +10 -0
  194. package/src/index.ts +59 -0
  195. package/src/interactions.ts +48 -0
  196. package/src/logger.ts +147 -0
  197. package/src/media.test.ts +160 -0
  198. package/src/media.ts +355 -0
  199. package/src/page-log.test.ts +163 -0
  200. package/src/page-log.ts +124 -0
  201. package/src/preview-compose.test.ts +637 -0
  202. package/src/preview-compose.ts +656 -0
  203. package/src/preview-scanner.test.ts +96 -0
  204. package/src/preview-scanner.ts +214 -0
  205. package/src/proposal-status.test.ts +69 -0
  206. package/src/proposal-status.ts +40 -0
  207. package/src/responsive-images.test.ts +104 -0
  208. package/src/responsive-images.ts +151 -0
  209. package/src/result.ts +29 -0
  210. package/src/safe-keys.ts +21 -0
  211. package/src/seo.test.ts +194 -0
  212. package/src/seo.ts +233 -0
  213. package/src/skills.ts +48 -0
  214. package/src/snapshots.test.ts +80 -0
  215. package/src/snapshots.ts +81 -0
  216. package/src/strip-cdata.test.ts +41 -0
  217. package/src/strip-cdata.ts +50 -0
  218. package/src/structured-sets.ts +114 -0
  219. package/src/subagents.test.ts +262 -0
  220. package/src/subagents.ts +432 -0
  221. package/src/template-engine.test.ts +379 -0
  222. package/src/template-engine.ts +520 -0
  223. package/src/theme-gradient.test.ts +92 -0
  224. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  225. package/src/theme-importers/auto-detect.ts +84 -0
  226. package/src/theme-importers/css-comments.ts +15 -0
  227. package/src/theme-importers/dtcg.ts +106 -0
  228. package/src/theme-importers/loose.ts +76 -0
  229. package/src/theme-importers/shadcn.ts +133 -0
  230. package/src/theme-importers/style-dictionary.ts +125 -0
  231. package/src/theme-importers/tailwind.ts +217 -0
  232. package/src/theme-literal-binding.test.ts +71 -0
  233. package/src/theme-literal-binding.ts +159 -0
  234. package/src/theme-motion.test.ts +115 -0
  235. package/src/theme-normalize-envelope.test.ts +43 -0
  236. package/src/theme-normalize-gradient.test.ts +135 -0
  237. package/src/theme-normalize.ts +661 -0
  238. package/src/theme-ramp.ts +187 -0
  239. package/src/theme-render-sanitize.test.ts +45 -0
  240. package/src/theme-render.test.ts +119 -0
  241. package/src/theme-render.ts +487 -0
  242. package/src/theme-shadow.test.ts +56 -0
  243. package/src/themes-errors.ts +199 -0
  244. package/src/themes.ts +842 -0
  245. package/src/version.ts +66 -0
  246. package/dist/translation.d.ts +0 -127
  247. package/dist/translation.d.ts.map +0 -1
  248. package/dist/translation.js +0 -208
  249. package/dist/translation.js.map +0 -1
@@ -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
+ });