@caelo-cms/shared 0.10.21 → 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.
Files changed (241) hide show
  1. package/dist/ai-tools.d.ts +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  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 +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -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-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
@@ -0,0 +1,661 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.0 — Loose-name → canonical-DTCG-path normalizer for theme
5
+ * tokens (#45, follow-up comment §1).
6
+ *
7
+ * The AI naturally sends prose-shaped inputs ("set primary color to
8
+ * #ff6600", "use Inter for headings") — translating that into
9
+ * `{ primaryColor: "#ff6600", fontHeading: "Inter" }`. The canonical
10
+ * DTCG path is `color.primary.$value` / `typography.heading.fontFamily`.
11
+ * The server normalizes loose names to canonical paths so the AI never
12
+ * has to know the canonical surface up front.
13
+ *
14
+ * Coverage (from the issue's normalization table):
15
+ *
16
+ * primaryColor, colorPrimary, primary + color value → color.primary
17
+ * fontHeading, headingFont, heading-font → typography.heading.fontFamily
18
+ * spacingLg, spacing-lg, lg + CSS length → spacing.lg
19
+ * radiusMd, borderRadius, rounded-md → radius.md
20
+ * shadowSm, shadow-sm, boxShadow → shadow.sm
21
+ * gradient, heroGradient, gradientHero, gradient.hero → gradient.hero
22
+ * color.primary.$value (canonical DTCG path) → passthrough
23
+ * --color-primary (CSS-var) → color.primary
24
+ * "{color.brand.primary}" (DTCG alias) → passthrough at value layer
25
+ *
26
+ * Ambiguity (no value-shape signal) returns `UnknownTokenName` with
27
+ * did-you-mean suggestions, per CLAUDE.md §11 "Failure surfaces are
28
+ * AI-actionable".
29
+ */
30
+
31
+ import { InvalidColorValue, TokenCategoryMismatch, UnknownTokenName } from "./themes-errors.js";
32
+
33
+ export type CanonicalPath = string;
34
+
35
+ export interface NormalizeResult {
36
+ /**
37
+ * Map from canonical DTCG path (e.g. "color.primary") to the value
38
+ * the caller supplied. The ops layer rebuilds the DTCG leaf
39
+ * (`{$value: <value>, $type: <inferred>}`) from this.
40
+ */
41
+ readonly set: Record<CanonicalPath, unknown>;
42
+ /**
43
+ * Inferred `$type` per canonical path. The renderer also knows the
44
+ * category from the path prefix, but the ops layer uses this to set
45
+ * `$type` on the stored token.
46
+ */
47
+ readonly types: Record<
48
+ CanonicalPath,
49
+ "color" | "dimension" | "typography" | "shadow" | "duration" | "cubicBezier" | "gradient"
50
+ >;
51
+ /** Echo-back list for the AI tool's result content. */
52
+ readonly canonicalPaths: readonly CanonicalPath[];
53
+ }
54
+
55
+ const COLOR_VALUE_REGEX =
56
+ /^(#[0-9a-fA-F]{3,8}|(oklch|rgb|rgba|hsl|hsla|lab|lch|color|hwb)\(.+\)|transparent|currentColor)$/;
57
+ const CSS_LENGTH_REGEX = /^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex)?$/;
58
+ const DURATION_REGEX = /^\d+(\.\d+)?(ms|s)$/;
59
+ const ALIAS_REGEX = /^\{[a-zA-Z0-9_.-]+\}$/;
60
+ const SHADOW_VALUE_REGEX = /^(-?\d+(\.\d+)?(rem|em|px|%)? *){2,5}(.+)?$/;
61
+ // A CSS `*-gradient(...)` value. Mirrors (loosely) the stricter Zod
62
+ // `gradientValueString` in themes.ts — the normalizer only needs to
63
+ // RECOGNISE gradient strings to route them to the gradient category and
64
+ // infer `$type: "gradient"`; the document validator enforces the exact
65
+ // safe shape (no url(), no `;<>{}` breakout) downstream.
66
+ const GRADIENT_VALUE_REGEX = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(.+\)$/i;
67
+
68
+ /**
69
+ * The canonical "well-known" tokens the AI is most likely to mean when
70
+ * it sends a bare or category-prefixed name. Used both as the
71
+ * normalization target AND as the suggestion pool when an input is
72
+ * ambiguous.
73
+ */
74
+ const KNOWN_CANONICAL_PATHS: readonly CanonicalPath[] = [
75
+ // colors (shadcn-inspired semantic set)
76
+ "color.background",
77
+ "color.foreground",
78
+ "color.primary",
79
+ "color.primary-foreground",
80
+ "color.secondary",
81
+ "color.secondary-foreground",
82
+ "color.accent",
83
+ "color.accent-foreground",
84
+ "color.muted",
85
+ "color.muted-foreground",
86
+ "color.card",
87
+ "color.card-foreground",
88
+ "color.border",
89
+ "color.ring",
90
+ "color.destructive",
91
+ "color.destructive-foreground",
92
+ "color.warning",
93
+ "color.success",
94
+ // typography (heading + body + display, each with fontFamily as the
95
+ // primary loose-name target; fontSize / fontWeight are addressable
96
+ // via direct DTCG paths)
97
+ "typography.heading.fontFamily",
98
+ "typography.body.fontFamily",
99
+ "typography.display.fontFamily",
100
+ "typography.mono.fontFamily",
101
+ // spacing scale (Tailwind-shaped)
102
+ "spacing.xs",
103
+ "spacing.sm",
104
+ "spacing.md",
105
+ "spacing.lg",
106
+ "spacing.xl",
107
+ "spacing.2xl",
108
+ // radius
109
+ "radius.sm",
110
+ "radius.md",
111
+ "radius.lg",
112
+ "radius.full",
113
+ // shadow
114
+ "shadow.sm",
115
+ "shadow.md",
116
+ "shadow.lg",
117
+ "shadow.xl",
118
+ // gradient (issue #153 — CSS gradient strings emitted as
119
+ // `--gradient-<name>`; `hero` + `subtle` are the seeded slots)
120
+ "gradient.hero",
121
+ "gradient.subtle",
122
+ ];
123
+
124
+ interface CategoryDef {
125
+ readonly category:
126
+ | "color"
127
+ | "typography"
128
+ | "spacing"
129
+ | "radius"
130
+ | "shadow"
131
+ | "duration"
132
+ | "gradient";
133
+ /** Name-shape heuristics that hint at this category. */
134
+ readonly nameHints: readonly RegExp[];
135
+ /** Type that gets stored at the leaf's `$type` field. */
136
+ readonly inferredType: "color" | "dimension" | "typography" | "shadow" | "duration" | "gradient";
137
+ /** Builds the canonical DTCG path from the basename. */
138
+ readonly buildPath: (basename: string) => CanonicalPath;
139
+ }
140
+
141
+ const CATEGORIES: readonly CategoryDef[] = [
142
+ {
143
+ category: "color",
144
+ nameHints: [
145
+ /color/i,
146
+ /^bg$/i,
147
+ /background/i,
148
+ /foreground/i,
149
+ /destructive/i,
150
+ /primary$/i,
151
+ /secondary$/i,
152
+ /accent$/i,
153
+ /muted$/i,
154
+ /ring$/i,
155
+ /border$/i,
156
+ ],
157
+ inferredType: "color",
158
+ buildPath: (basename) => `color.${basename}`,
159
+ },
160
+ {
161
+ category: "typography",
162
+ nameHints: [/font/i, /typography/i, /heading/i, /body/i, /display/i, /mono/i],
163
+ inferredType: "typography",
164
+ // v0.11.0 fix (#45 review thread): loose typography names target
165
+ // the composite ROOT (`typography.<name>`) — themeTypographyComposite
166
+ // Zod requires `$value` to be an object. The value gets wrapped as
167
+ // `{fontFamily: <input>}` in normalizeTokens (see compositeWrap
168
+ // below). The original sub-path shape (`typography.X.fontFamily`)
169
+ // produced documents Zod rejected.
170
+ buildPath: (basename) => `typography.${basename}`,
171
+ },
172
+ {
173
+ category: "spacing",
174
+ nameHints: [/^space/i, /^spacing/i, /^gap/i, /^padding/i, /^margin/i],
175
+ inferredType: "dimension",
176
+ buildPath: (basename) => `spacing.${basename}`,
177
+ },
178
+ {
179
+ category: "radius",
180
+ nameHints: [/radius/i, /rounded/i, /corner/i, /^border-?radius$/i],
181
+ inferredType: "dimension",
182
+ buildPath: (basename) => `radius.${basename}`,
183
+ },
184
+ {
185
+ category: "shadow",
186
+ nameHints: [/shadow/i, /elevation/i, /^box-?shadow$/i],
187
+ inferredType: "shadow",
188
+ buildPath: (basename) => `shadow.${basename}`,
189
+ },
190
+ {
191
+ // issue #153 — gradient tokens. Loose names (`gradient`,
192
+ // `heroGradient`, `gradientHero`) + the direct DTCG path
193
+ // (`gradient.hero`) all land in `gradient.<name>` with
194
+ // `$type: "gradient"`. Without this category the normalizer threw
195
+ // `UnknownTokenName` on every loose gradient name (the #1 red-error
196
+ // class in migration chat).
197
+ category: "gradient",
198
+ nameHints: [/gradient/i],
199
+ inferredType: "gradient",
200
+ buildPath: (basename) => `gradient.${basename}`,
201
+ },
202
+ {
203
+ category: "duration",
204
+ nameHints: [/duration/i, /timing/i],
205
+ inferredType: "duration",
206
+ buildPath: (basename) => `duration.${basename}`,
207
+ },
208
+ ];
209
+
210
+ const DEFAULT_TIER = "sm"; // shadowSm / radiusMd default
211
+
212
+ /**
213
+ * Normalize a record of loose-name → value pairs into canonical DTCG
214
+ * paths. Throws `UnknownTokenName` on the first ambiguous entry so the
215
+ * AI gets a single concrete next-step rather than a wall of partial
216
+ * results.
217
+ */
218
+ export function normalizeTokens(input: Record<string, unknown>): NormalizeResult {
219
+ const set: Record<CanonicalPath, unknown> = {};
220
+ const types: Record<CanonicalPath, NormalizeResult["types"][string]> = {};
221
+ const paths: CanonicalPath[] = [];
222
+
223
+ for (const [rawName, rawValueIn] of Object.entries(input)) {
224
+ // issue #242 (F11) — tolerate the two encodings the AI actually
225
+ // sends for composite values, then validate content strictly:
226
+ // 1. a JSON-encoded object string → parse it;
227
+ // 2. the full DTCG envelope `{$type?, $value}` → unwrap to $value
228
+ // (storing the envelope verbatim would nest `$value.$value`
229
+ // and bounce off the document schema).
230
+ let rawValue = rawValueIn;
231
+ if (typeof rawValue === "string" && rawValue.trim().startsWith("{")) {
232
+ try {
233
+ rawValue = JSON.parse(rawValue) as unknown;
234
+ } catch {
235
+ // Not JSON after all — keep the literal string.
236
+ }
237
+ }
238
+ if (
239
+ rawValue !== null &&
240
+ typeof rawValue === "object" &&
241
+ !Array.isArray(rawValue) &&
242
+ "$value" in (rawValue as Record<string, unknown>)
243
+ ) {
244
+ rawValue = (rawValue as Record<string, unknown>).$value;
245
+ }
246
+ const resolved = resolveOne(rawName, rawValue);
247
+ // v0.11.0 fix (#45 review thread on theme-normalize.ts:149) —
248
+ // typography is a composite; `$value` MUST be an object per the
249
+ // Zod schema. When the loose input (or a direct `typography.X.<sub>`
250
+ // DTCG path, or a `--font-X` / `--text-X` CSS-var) implied a single
251
+ // sub-field, wrap the scalar value as `{[subField]: value}` so the
252
+ // resulting document validates. applyDtcgWrites merges this into
253
+ // any existing typography composite at the path so partial updates
254
+ // don't erase sibling sub-fields.
255
+ let storedValue: unknown = rawValue;
256
+ if (resolved.compositeWrap && typeof rawValue === "string" && !ALIAS_REGEX.test(rawValue)) {
257
+ storedValue = { [resolved.compositeWrap]: rawValue };
258
+ } else if (
259
+ resolved.inferredType === "gradient" &&
260
+ typeof rawValue === "object" &&
261
+ rawValue !== null &&
262
+ !Array.isArray(rawValue)
263
+ ) {
264
+ // issue #153 — the AI sometimes sends a structured gradient
265
+ // (`{type, angle, stops}`) instead of the CSS string the DTCG
266
+ // gradient token stores. Fold it into the canonical
267
+ // `linear-gradient(...)` form so the document validates and the
268
+ // renderer emits a real `--gradient-<name>`. Unrecognised shapes
269
+ // pass through untouched so the document validator produces the
270
+ // actionable "gradient must be a CSS *-gradient(...) value" error
271
+ // rather than us silently inventing a gradient (CLAUDE.md §2).
272
+ storedValue = gradientObjectToCss(rawValue as Record<string, unknown>) ?? rawValue;
273
+ }
274
+ set[resolved.path] = storedValue;
275
+ types[resolved.path] = resolved.inferredType;
276
+ paths.push(resolved.path);
277
+ }
278
+
279
+ return { set, types, canonicalPaths: paths };
280
+ }
281
+
282
+ interface ResolvedToken {
283
+ readonly path: CanonicalPath;
284
+ readonly inferredType: NormalizeResult["types"][string];
285
+ /**
286
+ * When set, normalizeTokens wraps the raw value as
287
+ * `{[compositeWrap]: rawValue}` so the resulting leaf matches the
288
+ * typography (or future) composite `$value` shape. Unset for flat
289
+ * categories (color / dimension / shadow / duration / cubicBezier).
290
+ */
291
+ readonly compositeWrap?: string;
292
+ }
293
+
294
+ function resolveOne(rawName: string, rawValue: unknown): ResolvedToken {
295
+ // 1. Direct canonical DTCG paths: pass through unchanged. Typography
296
+ // sub-paths (`typography.X.fontFamily` / `.fontSize` / …) collapse
297
+ // onto the composite root so the leaf carries `$value: {sub: val}`.
298
+ if (/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+(\.\$value)?$/i.test(rawName)) {
299
+ const path = rawName.replace(/\.\$value$/, "");
300
+ const parts = path.split(".");
301
+ const category = parts[0] ?? "";
302
+ if (
303
+ category === "typography" &&
304
+ parts.length === 3 &&
305
+ isTypographyCompositeSubField(parts[2])
306
+ ) {
307
+ return {
308
+ path: `${parts[0]}.${parts[1]}`,
309
+ inferredType: "typography",
310
+ compositeWrap: parts[2],
311
+ };
312
+ }
313
+ validateValueShape(category, rawValue, path);
314
+ return { path, inferredType: inferTypeFromPath(path, rawValue) };
315
+ }
316
+
317
+ // 2. CSS-var form: `--color-primary` → `color.primary`.
318
+ if (rawName.startsWith("--")) {
319
+ const stripped = rawName.slice(2);
320
+ const firstHyphen = stripped.indexOf("-");
321
+ if (firstHyphen > 0) {
322
+ const category = stripped.slice(0, firstHyphen);
323
+ const rest = stripped.slice(firstHyphen + 1);
324
+ // Tailwind 4 typography split (--font + --text) maps onto the
325
+ // composite root with the right sub-field set; the value gets
326
+ // wrapped into `{fontFamily}` / `{fontSize}` by normalizeTokens
327
+ // so the leaf matches themeTypographyComposite.
328
+ if (category === "font") {
329
+ return {
330
+ path: `typography.${rest}`,
331
+ inferredType: "typography",
332
+ compositeWrap: "fontFamily",
333
+ };
334
+ }
335
+ if (category === "text") {
336
+ validateValueShape("spacing", rawValue, `typography.${rest}`);
337
+ return {
338
+ path: `typography.${rest}`,
339
+ inferredType: "typography",
340
+ compositeWrap: "fontSize",
341
+ };
342
+ }
343
+ const path = `${category}.${rest}`;
344
+ validateValueShape(category, rawValue, path);
345
+ return { path, inferredType: inferTypeFromPath(path, rawValue) };
346
+ }
347
+ }
348
+
349
+ // 3. Name-shape category hint.
350
+ const matched: CategoryDef[] = [];
351
+ for (const cat of CATEGORIES) {
352
+ if (cat.nameHints.some((re) => re.test(rawName))) matched.push(cat);
353
+ }
354
+
355
+ // 4. Value-shape signal — disambiguates when the name is bare or
356
+ // matched multiple categories.
357
+ const valueCategory = sniffCategoryFromValue(rawValue);
358
+
359
+ let chosen: CategoryDef | undefined;
360
+ if (matched.length === 1) {
361
+ chosen = matched[0];
362
+ } else if (matched.length > 1) {
363
+ // Prefer the category whose inferred type matches the value shape.
364
+ chosen = matched.find((m) => m.inferredType === valueCategory) ?? matched[0];
365
+ } else if (valueCategory) {
366
+ // Bare name but value hints — pick category by value.
367
+ chosen = CATEGORIES.find((c) => c.inferredType === valueCategory);
368
+ }
369
+
370
+ if (!chosen) {
371
+ throw new UnknownTokenName(rawName, didYouMean(rawName));
372
+ }
373
+
374
+ // 5. Extract basename: strip category prefix + camel/kebab.
375
+ const basename = extractBasename(rawName, chosen.category);
376
+ const path = basename
377
+ ? chosen.buildPath(basename)
378
+ : // Bare category word ("color" / "shadow") — pick the default tier
379
+ // (`primary` for color, `sm` for shadow/radius, etc.).
380
+ chosen.buildPath(defaultTier(chosen.category));
381
+ validateValueShape(chosen.category, rawValue, path);
382
+ // Typography is a composite — loose names imply a single sub-field
383
+ // (fontFamily by convention). The wrap happens in normalizeTokens.
384
+ if (chosen.category === "typography") {
385
+ return { path, inferredType: chosen.inferredType, compositeWrap: "fontFamily" };
386
+ }
387
+ return { path, inferredType: chosen.inferredType };
388
+ }
389
+
390
+ /**
391
+ * Sub-field keys recognised by `themeTypographyComposite`. Used to
392
+ * detect direct DTCG sub-paths like `typography.heading.fontFamily`
393
+ * and collapse them onto the composite root with a wrapped value.
394
+ */
395
+ function isTypographyCompositeSubField(segment: string | undefined): segment is string {
396
+ return (
397
+ segment === "fontFamily" ||
398
+ segment === "fontSize" ||
399
+ segment === "fontWeight" ||
400
+ segment === "lineHeight" ||
401
+ segment === "letterSpacing"
402
+ );
403
+ }
404
+
405
+ /**
406
+ * v0.11.0 (#45 AC #7) — fail-fast value-shape validation for the
407
+ * AI-actionable error surface. Catches:
408
+ *
409
+ * - color slot + string value that isn't a valid CSS color
410
+ * → InvalidColorValue (carries supportedFormats list).
411
+ * - any slot + value whose sniffed category disagrees with the
412
+ * resolved slot's category → TokenCategoryMismatch (carries
413
+ * expected vs got).
414
+ *
415
+ * Aliases (`{color.primary}`) bypass — they're resolved at render
416
+ * time and may legitimately point at a value of any shape. Non-string
417
+ * values (numbers, composite objects) also bypass because the AI
418
+ * passes those only on direct DTCG paths where the Zod composite
419
+ * schema validates the shape downstream.
420
+ *
421
+ * Typography sub-paths (`typography.<name>.fontFamily`) skip the
422
+ * check because fontFamily accepts free-form strings.
423
+ */
424
+ function validateValueShape(category: string, value: unknown, canonicalPath: string): void {
425
+ if (typeof value !== "string") return;
426
+ if (ALIAS_REGEX.test(value)) return;
427
+
428
+ if (category === "color") {
429
+ if (COLOR_VALUE_REGEX.test(value)) return;
430
+ const sniffed = sniffCategoryFromValue(value);
431
+ if (sniffed && sniffed !== "color") {
432
+ throw new TokenCategoryMismatch(canonicalPath, "color", sniffed);
433
+ }
434
+ throw new InvalidColorValue(value);
435
+ }
436
+
437
+ if (category === "spacing" || category === "radius" || category === "breakpoint") {
438
+ const sniffed = sniffCategoryFromValue(value);
439
+ if (sniffed && sniffed !== "dimension") {
440
+ throw new TokenCategoryMismatch(canonicalPath, "dimension", sniffed);
441
+ }
442
+ return;
443
+ }
444
+
445
+ if (category === "duration") {
446
+ const sniffed = sniffCategoryFromValue(value);
447
+ if (sniffed && sniffed !== "duration") {
448
+ throw new TokenCategoryMismatch(canonicalPath, "duration", sniffed);
449
+ }
450
+ return;
451
+ }
452
+
453
+ if (category === "gradient") {
454
+ if (GRADIENT_VALUE_REGEX.test(value)) return;
455
+ const sniffed = sniffCategoryFromValue(value);
456
+ if (sniffed && sniffed !== "gradient") {
457
+ // e.g. a flat color string sent to `gradient.hero` — name the
458
+ // mismatch instead of letting Zod reject with a vaguer message.
459
+ throw new TokenCategoryMismatch(canonicalPath, "gradient", sniffed);
460
+ }
461
+ // Not a recognised gradient and not clearly another category — defer
462
+ // to the document validator's precise "gradient must be a CSS
463
+ // *-gradient(...) value" message rather than guessing here.
464
+ return;
465
+ }
466
+
467
+ // shadow / typography composites + unknown categories: caller's
468
+ // responsibility to pass a structurally-correct value; Zod catches
469
+ // the rest at validateThemeTokens time.
470
+ }
471
+
472
+ function inferTypeFromPath(path: string, value: unknown): NormalizeResult["types"][string] {
473
+ const head = path.split(".")[0] ?? "";
474
+ switch (head) {
475
+ case "color":
476
+ return "color";
477
+ case "typography":
478
+ // Composite vs sub-field. A `.fontFamily` / `.fontSize` etc.
479
+ // suffix lives inside the composite — type stays "typography"
480
+ // for top-level + "dimension" / "color" / etc. for sub-fields
481
+ // depending on which slot. Caller stores at the composite root
482
+ // anyway, so this stays "typography" for any typography.* path.
483
+ return "typography";
484
+ case "spacing":
485
+ case "radius":
486
+ case "breakpoint":
487
+ return "dimension";
488
+ case "shadow":
489
+ return "shadow";
490
+ case "gradient":
491
+ // issue #153 — MUST return "gradient" (not the sniffed value
492
+ // category). The gradient token's `$type` is `z.literal("gradient")`;
493
+ // before this case, `gradient.hero` fell through to `default`, the
494
+ // gradient string sniffed as no known category → "dimension", and
495
+ // the stamped `$type: "dimension"` bounced off the document
496
+ // validator ("gradient token invalid: expected \"gradient\"").
497
+ return "gradient";
498
+ case "duration":
499
+ return "duration";
500
+ case "ease":
501
+ return "cubicBezier";
502
+ default: {
503
+ const sniffed = sniffCategoryFromValue(value);
504
+ return sniffed ?? "dimension";
505
+ }
506
+ }
507
+ }
508
+
509
+ function sniffCategoryFromValue(value: unknown): NormalizeResult["types"][string] | undefined {
510
+ if (typeof value !== "string") return undefined;
511
+ if (ALIAS_REGEX.test(value)) return undefined; // alias has no value shape
512
+ // Gradient before color: a `linear-gradient(...)` string could loosely
513
+ // read as a `color(...)`-shaped function otherwise.
514
+ if (GRADIENT_VALUE_REGEX.test(value)) return "gradient";
515
+ if (COLOR_VALUE_REGEX.test(value)) return "color";
516
+ if (DURATION_REGEX.test(value)) return "duration";
517
+ if (SHADOW_VALUE_REGEX.test(value) && /\s/.test(value)) return "shadow";
518
+ if (CSS_LENGTH_REGEX.test(value)) return "dimension";
519
+ return undefined;
520
+ }
521
+
522
+ /**
523
+ * Pull the meaningful basename out of a loose name. `primaryColor` /
524
+ * `colorPrimary` / `primary-color` all reduce to `primary`. Plural
525
+ * forms (`spacingLg`) lose the category prefix to leave the tier (`lg`).
526
+ *
527
+ * Returns null when the input IS the category word alone.
528
+ */
529
+ function extractBasename(rawName: string, category: string): string | null {
530
+ // Lowercase camelCase → kebab-case for uniform tokenisation.
531
+ const kebab = rawName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
532
+ const parts = kebab.split(/[-_]+/);
533
+
534
+ // Filter out category aliases (font / typography / heading-related
535
+ // names) AND the literal category itself.
536
+ const aliasesFor: Record<string, readonly string[]> = {
537
+ color: ["color"],
538
+ typography: ["font", "typography"],
539
+ spacing: ["space", "spacing", "gap", "padding", "margin"],
540
+ radius: ["radius", "rounded", "border", "borderradius", "border-radius", "corner"],
541
+ shadow: ["shadow", "box", "boxshadow", "box-shadow", "elevation"],
542
+ duration: ["duration", "timing"],
543
+ gradient: ["gradient"],
544
+ };
545
+ const aliases = new Set(aliasesFor[category] ?? [category]);
546
+ const meaningful = parts.filter((p) => p.length > 0 && !aliases.has(p));
547
+ if (meaningful.length === 0) return null;
548
+ return meaningful.join("-");
549
+ }
550
+
551
+ function defaultTier(category: string): string {
552
+ switch (category) {
553
+ case "color":
554
+ return "primary";
555
+ case "typography":
556
+ return "body";
557
+ case "spacing":
558
+ return "md";
559
+ case "radius":
560
+ return "md";
561
+ case "shadow":
562
+ return DEFAULT_TIER;
563
+ case "duration":
564
+ return "fast";
565
+ case "gradient":
566
+ // The seeded default theme ships `gradient.hero` + `gradient.subtle`;
567
+ // a bare `gradient` almost always means the primary/hero surface.
568
+ return "hero";
569
+ default:
570
+ return "default";
571
+ }
572
+ }
573
+
574
+ /**
575
+ * issue #153 — best-effort structured-gradient → CSS-string converter.
576
+ *
577
+ * The DTCG gradient token stores a CSS `*-gradient(...)` string (see
578
+ * `themeGradientToken` in themes.ts). When the AI sends a structured
579
+ * shape instead, fold the RECOGNISED subset into that string:
580
+ *
581
+ * `{ type?: "linear"|"radial"|"conic", angle?: string|number,
582
+ * direction?: string, stops: Array<string | {color, position?}> }`
583
+ *
584
+ * → `linear-gradient(135deg, #4f46e5, #7c3aed 100%)`
585
+ *
586
+ * Returns `undefined` (caller keeps the raw value so the document
587
+ * validator emits its precise error) when the shape carries no usable
588
+ * stops — we never fabricate a gradient from nothing (CLAUDE.md §2).
589
+ */
590
+ function gradientObjectToCss(obj: Record<string, unknown>): string | undefined {
591
+ const rawStops = obj.stops;
592
+ if (!Array.isArray(rawStops) || rawStops.length < 2) return undefined;
593
+
594
+ const stops: string[] = [];
595
+ for (const s of rawStops) {
596
+ if (typeof s === "string" && s.trim().length > 0) {
597
+ stops.push(s.trim());
598
+ continue;
599
+ }
600
+ if (s !== null && typeof s === "object" && !Array.isArray(s)) {
601
+ const stop = s as Record<string, unknown>;
602
+ const color = typeof stop.color === "string" ? stop.color.trim() : undefined;
603
+ if (!color) return undefined;
604
+ const pos =
605
+ typeof stop.position === "string"
606
+ ? stop.position.trim()
607
+ : typeof stop.position === "number"
608
+ ? `${stop.position}%`
609
+ : undefined;
610
+ stops.push(pos ? `${color} ${pos}` : color);
611
+ continue;
612
+ }
613
+ return undefined; // unrecognised stop entry — don't guess
614
+ }
615
+
616
+ const type =
617
+ obj.type === "radial" || obj.type === "conic" || obj.type === "linear" ? obj.type : "linear";
618
+
619
+ // Leading orientation argument. Linear/conic accept an angle; radial
620
+ // accepts a shape/extent hint via `direction`. Only linear gets a
621
+ // default angle so a bare `{stops}` still reads as a diagonal sweep.
622
+ let head = "";
623
+ if (typeof obj.direction === "string" && obj.direction.trim().length > 0) {
624
+ head = obj.direction.trim();
625
+ } else if (obj.angle !== undefined && obj.angle !== null) {
626
+ const a = obj.angle;
627
+ head = typeof a === "number" ? `${a}deg` : String(a).trim();
628
+ } else if (type === "linear") {
629
+ head = "135deg";
630
+ }
631
+
632
+ const args = head ? `${head}, ${stops.join(", ")}` : stops.join(", ");
633
+ return `${type}-gradient(${args})`;
634
+ }
635
+
636
+ /**
637
+ * Closest-match suggestions for an unknown input. Cheap edit-distance
638
+ * over the canonical-paths set — good enough that the AI gets a useful
639
+ * "did you mean X?" without pulling a fuzzy-search dep.
640
+ */
641
+ export function didYouMean(input: string): readonly string[] {
642
+ const lower = input.toLowerCase();
643
+ return KNOWN_CANONICAL_PATHS.map((p) => ({ path: p, score: similarity(lower, p.toLowerCase()) }))
644
+ .filter((c) => c.score >= 0.4)
645
+ .sort((a, b) => b.score - a.score)
646
+ .slice(0, 3)
647
+ .map((c) => c.path);
648
+ }
649
+
650
+ function similarity(a: string, b: string): number {
651
+ const tokensA = new Set(tokenise(a));
652
+ const tokensB = new Set(tokenise(b));
653
+ let hits = 0;
654
+ for (const t of tokensA) if (tokensB.has(t)) hits++;
655
+ const max = Math.max(tokensA.size, tokensB.size);
656
+ return max === 0 ? 0 : hits / max;
657
+ }
658
+
659
+ function tokenise(s: string): string[] {
660
+ return s.split(/[.\-_]/).filter((p) => p.length > 0);
661
+ }