@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
package/dist/themes.js ADDED
@@ -0,0 +1,697 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * v0.11.0 — DTCG-aligned Zod schemas for the `themes` primitive (#45).
4
+ *
5
+ * Mirrors the W3C Design Tokens Format spec (2025.10 stable). Storage
6
+ * is a single jsonb document grouped by category (color / typography /
7
+ * spacing / radius / shadow / motion / breakpoint); each leaf carries a
8
+ * `$value` (required), optional `$type`, optional `$description`, and
9
+ * may use DTCG aliasing (`{group.token}`) to reference another token.
10
+ *
11
+ * Why DTCG: import/export from Figma / Tokens Studio / Style Dictionary
12
+ * works out of the box. The admin UI doesn't show raw DTCG to
13
+ * operators — it renders categorized panels — but the storage layer is
14
+ * the lingua franca so design tooling round-trips cleanly.
15
+ *
16
+ * Caelo extensions over plain DTCG:
17
+ * - Color tokens may carry `{ light, dark }` instead of a flat `$value`
18
+ * so the renderer can emit `:root { … }` + `:root.dark { … }` from
19
+ * one declaration.
20
+ * - The `$extensions` namespace is tolerated (DTCG-spec-compatible)
21
+ * for tooling-specific metadata; we never inspect it server-side.
22
+ */
23
+ import { z } from "zod";
24
+ import { isUnsafeKey } from "./safe-keys.js";
25
+ // ────────────────────────────────────────────────────────────────────
26
+ // Primitives
27
+ // ────────────────────────────────────────────────────────────────────
28
+ /** DTCG alias: `"{group.token}"` references another token by path. */
29
+ const aliasRegex = /^\{[a-zA-Z0-9_.-]+\}$/;
30
+ const aliasString = z.string().regex(aliasRegex, "DTCG alias must look like '{group.token}'");
31
+ /**
32
+ * Color value: hex, oklch(...), rgb(...), hsl(...), or a named color.
33
+ * Loose enough to accept anything a browser would render; strict
34
+ * rejection lives at the loose-name normalizer where the AI can
35
+ * recover with a structured error.
36
+ */
37
+ const colorValueString = z
38
+ .string()
39
+ .min(1)
40
+ .max(200)
41
+ .regex(/^(#[0-9a-fA-F]{3,8}|(oklch|rgb|rgba|hsl|hsla|lab|lch|color|hwb)\(.+\)|transparent|currentColor|[a-zA-Z]+)$/, "color value must be #hex, oklch(...), rgb(...), or a CSS named color");
42
+ /** Dimension: a CSS length / percentage / numeric value. */
43
+ const dimensionValueString = z
44
+ .string()
45
+ .min(1)
46
+ .max(80)
47
+ .regex(/^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex|fr|deg|rad|turn|s|ms)?$|^auto$|^0$/, "dimension must be a CSS length / number / percentage");
48
+ /** Font-weight: keyword or 1-1000 number. */
49
+ const fontWeightValue = z.union([
50
+ z.number().int().min(1).max(1000),
51
+ z.enum(["normal", "bold", "lighter", "bolder"]),
52
+ ]);
53
+ const optionalDescription = z.string().max(500).optional();
54
+ // ────────────────────────────────────────────────────────────────────
55
+ // Per-type token shapes
56
+ // ────────────────────────────────────────────────────────────────────
57
+ const flatOrAliasColor = z.union([colorValueString, aliasString]);
58
+ const flatOrAliasDimension = z.union([dimensionValueString, aliasString]);
59
+ /**
60
+ * Color token. Either a flat `$value` or a `{light, dark}` pair where
61
+ * the renderer emits the light value in `:root { … }` and the dark
62
+ * value in `:root.dark { … }`.
63
+ */
64
+ export const themeColorToken = z
65
+ .object({
66
+ $value: z.union([
67
+ flatOrAliasColor,
68
+ z
69
+ .object({
70
+ light: flatOrAliasColor,
71
+ dark: flatOrAliasColor,
72
+ })
73
+ .strict(),
74
+ ]),
75
+ $type: z.literal("color").optional(),
76
+ $description: optionalDescription,
77
+ $extensions: z.record(z.string(), z.unknown()).optional(),
78
+ })
79
+ .strict();
80
+ export const themeDimensionToken = z
81
+ .object({
82
+ $value: flatOrAliasDimension,
83
+ $type: z.literal("dimension").optional(),
84
+ $description: optionalDescription,
85
+ $extensions: z.record(z.string(), z.unknown()).optional(),
86
+ })
87
+ .strict();
88
+ /**
89
+ * Typography composite. Each sub-field is independently optional so a
90
+ * document can ship a heading family without forcing a size, and vice
91
+ * versa. The renderer emits one CSS variable per declared sub-field
92
+ * (`--font-<name>`, `--text-<name>`, `--font-weight-<name>`, ...).
93
+ */
94
+ export const themeTypographyComposite = z
95
+ .object({
96
+ $value: z.union([
97
+ z
98
+ .object({
99
+ fontFamily: z.string().min(1).max(200).optional(),
100
+ fontSize: dimensionValueString.optional(),
101
+ fontWeight: fontWeightValue.optional(),
102
+ lineHeight: z.union([z.number().positive(), dimensionValueString]).optional(),
103
+ letterSpacing: dimensionValueString.optional(),
104
+ })
105
+ .strict(),
106
+ aliasString,
107
+ ]),
108
+ $type: z.literal("typography").optional(),
109
+ $description: optionalDescription,
110
+ $extensions: z.record(z.string(), z.unknown()).optional(),
111
+ })
112
+ .strict();
113
+ /** Shadow composite (single or layered). */
114
+ const shadowValueObject = z
115
+ .object({
116
+ color: flatOrAliasColor,
117
+ offsetX: dimensionValueString,
118
+ offsetY: dimensionValueString,
119
+ blur: dimensionValueString,
120
+ spread: dimensionValueString.optional(),
121
+ inset: z.boolean().optional(),
122
+ })
123
+ .strict()
124
+ .refine((v) => {
125
+ // Reject negative blur — physically meaningless, signals AI confusion.
126
+ const m = /^-?(\d+(\.\d+)?)/.exec(v.blur);
127
+ return !m || Number(m[0]) >= 0;
128
+ }, { message: "shadow blur must be ≥ 0", path: ["blur"] });
129
+ /**
130
+ * Literal CSS `box-shadow` string — the form a model naturally emits
131
+ * ("0 4px 6px -1px rgba(0,0,0,0.1)", "none", comma-separated layers). The
132
+ * DTCG object form (`shadowValueObject`) is the canonical shape; this is the
133
+ * tolerant fallback so a correct CSS shadow isn't rejected as "must be a
134
+ * DTCG alias" (the same tolerance `gradientValueString` gives gradients).
135
+ * Char-class only (no nested quantifiers) → ReDoS-safe; the renderer emits
136
+ * the string verbatim.
137
+ */
138
+ const shadowValueString = z
139
+ .string()
140
+ .min(3)
141
+ .max(600)
142
+ .regex(/^(none|[\w\s.,%#()/-]+)$/i, "shadow must be a CSS box-shadow value, the DTCG object form, or an alias");
143
+ export const themeShadowComposite = z
144
+ .object({
145
+ $value: z.union([
146
+ shadowValueObject,
147
+ z.array(shadowValueObject).min(1),
148
+ aliasString,
149
+ shadowValueString,
150
+ ]),
151
+ $type: z.literal("shadow").optional(),
152
+ $description: optionalDescription,
153
+ $extensions: z.record(z.string(), z.unknown()).optional(),
154
+ })
155
+ .strict();
156
+ export const themeDurationToken = z
157
+ .object({
158
+ $value: z.union([
159
+ z.string().regex(/^\d+(\.\d+)?(ms|s)$/, "duration must end in ms or s"),
160
+ aliasString,
161
+ ]),
162
+ $type: z.literal("duration").optional(),
163
+ $description: optionalDescription,
164
+ $extensions: z.record(z.string(), z.unknown()).optional(),
165
+ })
166
+ .strict();
167
+ export const themeCubicBezierToken = z
168
+ .object({
169
+ $value: z.union([
170
+ z
171
+ .tuple([z.number().min(0).max(1), z.number(), z.number().min(0).max(1), z.number()])
172
+ .describe("[x1, y1, x2, y2]"),
173
+ aliasString,
174
+ ]),
175
+ $type: z.literal("cubicBezier").optional(),
176
+ $description: optionalDescription,
177
+ $extensions: z.record(z.string(), z.unknown()).optional(),
178
+ })
179
+ .strict();
180
+ /** The seven CSS `<easing-function>` keywords, in `transition-timing-function` grammar. */
181
+ const EASING_KEYWORDS = [
182
+ "linear",
183
+ "ease",
184
+ "ease-in",
185
+ "ease-out",
186
+ "ease-in-out",
187
+ "step-start",
188
+ "step-end",
189
+ ];
190
+ /**
191
+ * Easing value as a literal CSS string — the form a model naturally
192
+ * reaches for on `motion.easing`: a timing keyword (`ease-in-out`) or a
193
+ * literal `cubic-bezier(a, b, c, d)` / `steps(...)`. The DTCG
194
+ * `cubicBezier` *tuple* (`themeCubicBezierToken`) is the canonical
195
+ * structured shape; this is the tolerant fallback so a correct CSS
196
+ * easing isn't rejected as "Invalid input" (the same tolerance
197
+ * `shadowValueString` gives shadows and `gradientValueString` gradients).
198
+ *
199
+ * ReDoS-safe: the function-call forms use a bounded char class with no
200
+ * nested quantifiers, and exclude `;<>{}` so a value can never break out
201
+ * of the emitted declaration. The renderer emits the string verbatim.
202
+ */
203
+ // Longest-first so the alternation prefers `ease-in-out` over `ease`.
204
+ const EASING_KEYWORD_ALT = [...EASING_KEYWORDS].sort((a, b) => b.length - a.length).join("|");
205
+ const easingValueString = z
206
+ .string()
207
+ .min(1)
208
+ .max(80)
209
+ .regex(new RegExp(`^(?:${EASING_KEYWORD_ALT}|cubic-bezier\\([\\d\\s.,+-]+\\)|steps\\([\\w\\s.,+-]+\\))$`, "i"), `easing must be a keyword (${EASING_KEYWORDS.join("|")}) or a cubic-bezier(...) / steps(...) value`);
210
+ /**
211
+ * Easing token for `motion.easing`. Accepts, in order of how models
212
+ * emit them: a CSS easing keyword / `cubic-bezier(...)` string, the DTCG
213
+ * `cubicBezier` `[x1,y1,x2,y2]` tuple, or a DTCG alias. `$type` may be
214
+ * omitted, `cubicBezier` (DTCG), or `easing` (Caelo shorthand).
215
+ */
216
+ export const themeEasingToken = z
217
+ .object({
218
+ $value: z.union([
219
+ easingValueString,
220
+ z
221
+ .tuple([z.number().min(0).max(1), z.number(), z.number().min(0).max(1), z.number()])
222
+ .describe("[x1, y1, x2, y2]"),
223
+ aliasString,
224
+ ]),
225
+ $type: z.union([z.literal("cubicBezier"), z.literal("easing")]).optional(),
226
+ $description: optionalDescription,
227
+ $extensions: z.record(z.string(), z.unknown()).optional(),
228
+ })
229
+ .strict();
230
+ /**
231
+ * issue #153 — first-class gradient token. Pragmatic CSS-string form
232
+ * (`linear-gradient(135deg, #4f46e5, #7c3aed)`) rather than DTCG's
233
+ * structured-stops draft: the renderer emits values verbatim into
234
+ * `--gradient-<name>` and module CSS consumes them via `var()`, so the
235
+ * CSS string IS the useful representation. The value regex uses an
236
+ * unambiguous char class (ReDoS-aware per issue #113) and excludes
237
+ * `;<>{}` so a token can never break out of the emitted declaration;
238
+ * `sanitizeCssTokenValue` still runs at render as defense-in-depth.
239
+ */
240
+ const gradientValueString = z
241
+ .string()
242
+ .min(12)
243
+ .max(600)
244
+ .regex(/^(?:repeating-)?(?:linear|radial|conic)-gradient\([^;<>{}]*\)$/i, "gradient must be a CSS *-gradient(...) value (no url(), no declarations)")
245
+ .refine((v) => !/url\s*\(/i.test(v), "gradient must not contain url()");
246
+ export const themeGradientToken = z
247
+ .object({
248
+ $value: z.union([gradientValueString, aliasString]),
249
+ $type: z.literal("gradient").optional(),
250
+ $description: optionalDescription,
251
+ $extensions: z.record(z.string(), z.unknown()).optional(),
252
+ })
253
+ .strict();
254
+ /**
255
+ * Any token, in any category. The discriminator is the parent group
256
+ * key (`color.*` → color, `spacing.*` → dimension, ...), but DTCG
257
+ * doesn't strictly require `$type` so we accept any leaf carrying a
258
+ * valid `$value`. Per-category strictness is enforced at the
259
+ * normalizer + the renderer (which know which namespace the token
260
+ * is being emitted into).
261
+ */
262
+ const anyThemeToken = z.union([
263
+ themeColorToken,
264
+ themeDimensionToken,
265
+ themeTypographyComposite,
266
+ themeShadowComposite,
267
+ themeDurationToken,
268
+ themeCubicBezierToken,
269
+ themeEasingToken,
270
+ themeGradientToken,
271
+ ]);
272
+ const tokenGroupSchema = z.lazy(() => z.record(z.string(), z.unknown()).superRefine((obj, ctx) => {
273
+ for (const [k, v] of Object.entries(obj)) {
274
+ if (k.startsWith("$")) {
275
+ // DTCG group-level metadata. $description must be a string;
276
+ // every other $-prefixed key is tooling extension data and
277
+ // passes through unmodified (the spec leaves these open).
278
+ if (k === "$description" && typeof v !== "string") {
279
+ ctx.addIssue({
280
+ code: "custom",
281
+ path: [k],
282
+ message: "$description must be a string",
283
+ });
284
+ }
285
+ continue;
286
+ }
287
+ const sub = z.union([anyThemeToken, tokenGroupSchema]).safeParse(v);
288
+ if (!sub.success) {
289
+ for (const issue of sub.error.issues) {
290
+ ctx.addIssue({
291
+ code: "custom",
292
+ path: [k, ...issue.path],
293
+ message: issue.message,
294
+ });
295
+ }
296
+ }
297
+ }
298
+ }));
299
+ /**
300
+ * issue #153 — per-category leaf enforcement for the KNOWN vocabulary.
301
+ *
302
+ * The recursive group walker deliberately tolerates unknown shapes
303
+ * (DTCG leaves the category vocabulary open), but that tolerance had a
304
+ * hole: a leaf whose `$value` failed every token schema fell back to
305
+ * validating as a "group" whose `$`-keys pass through — so an invalid
306
+ * `color.primary` (or a `url()`-smuggling `gradient.hero`) passed the
307
+ * document boundary silently and STILL got emitted into CSS by the
308
+ * renderer. That is the exact silent-acceptance CLAUDE.md §2 forbids.
309
+ *
310
+ * Fix: inside the known categories, any object carrying `$value` IS a
311
+ * token and must match that category's schema. Unknown root categories
312
+ * keep the open-vocabulary tolerance (Figma / Tokens Studio imports).
313
+ */
314
+ const CATEGORY_TOKEN_SCHEMAS = {
315
+ color: themeColorToken,
316
+ gradient: themeGradientToken,
317
+ spacing: themeDimensionToken,
318
+ radius: themeDimensionToken,
319
+ breakpoint: themeDimensionToken,
320
+ typography: themeTypographyComposite,
321
+ shadow: themeShadowComposite,
322
+ // motion mixes duration + easing leaves (duration.fast, ease.smooth,
323
+ // easing.standard nested under motion or flat); accept either shape.
324
+ // themeEasingToken covers the CSS-string easings (`ease-in-out`,
325
+ // `cubic-bezier(...)`) a model naturally emits; themeCubicBezierToken
326
+ // covers the DTCG tuple; themeDurationToken/themeDimensionToken the
327
+ // timing leaves.
328
+ motion: z.union([
329
+ themeDurationToken,
330
+ themeCubicBezierToken,
331
+ themeEasingToken,
332
+ themeDimensionToken,
333
+ ]),
334
+ duration: themeDurationToken,
335
+ ease: z.union([themeCubicBezierToken, themeEasingToken]),
336
+ };
337
+ /**
338
+ * Per-category "what shape is accepted" clause, spliced into the
339
+ * rejection message so the AI can self-correct in one step (CLAUDE.md
340
+ * §11 — AI-actionable errors) instead of seeing a bare "Invalid input".
341
+ * Categories absent here fall back to the raw Zod issue message.
342
+ */
343
+ const CATEGORY_EXPECTATIONS = {
344
+ motion: 'a duration (e.g. "200ms"), a cubic-bezier(...) or easing keyword ' +
345
+ "(linear|ease|ease-in|ease-out|ease-in-out|step-start|step-end), or a dimension token",
346
+ ease: 'a cubic-bezier(...) string, an easing keyword (linear|ease|ease-in|ease-out|ease-in-out|step-start|step-end), a DTCG [x1,y1,x2,y2] tuple, or an alias like "{motion.ease}"',
347
+ duration: 'a duration string ending in ms or s (e.g. "200ms", "0.5s") or an alias',
348
+ };
349
+ /** Compact, human-readable render of a rejected `$value` for the error tail. */
350
+ function describeValue(value) {
351
+ if (typeof value === "string")
352
+ return JSON.stringify(value);
353
+ if (value === undefined)
354
+ return "undefined";
355
+ const s = JSON.stringify(value);
356
+ if (s === undefined)
357
+ return String(value);
358
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
359
+ }
360
+ /**
361
+ * Name the mistake when a value looks like a CSS shorthand packing several
362
+ * design tokens into one string. Design tokens are single-valued by
363
+ * definition, so `"180ms ease"` is not a malformed duration — it is a
364
+ * duration and an easing that belong in separate tokens, and saying so is
365
+ * what lets the caller fix it on the first try.
366
+ */
367
+ function shorthandHint(category, value) {
368
+ if (typeof value !== "string")
369
+ return "";
370
+ const v = value.trim();
371
+ if (category === "duration" || category === "motion") {
372
+ // A duration followed by anything else: "180ms ease", "0.2s ease-in-out".
373
+ if (/^\d*\.?\d+\s*(ms|s)\s+\S/i.test(v)) {
374
+ return (` — that looks like a CSS transition shorthand (duration + easing) in ONE token. ` +
375
+ `Design tokens hold a single value: put "${v.split(/\s+/)[0]}" in a duration token ` +
376
+ `and "${v.split(/\s+/).slice(1).join(" ")}" in an easing token.`);
377
+ }
378
+ }
379
+ return "";
380
+ }
381
+ function refineKnownCategoryLeaves(category, schema, node, path, ctx) {
382
+ if (node === null || typeof node !== "object" || Array.isArray(node))
383
+ return;
384
+ const obj = node;
385
+ if ("$value" in obj) {
386
+ const sub = schema.safeParse(obj);
387
+ if (!sub.success) {
388
+ const name = path[path.length - 1];
389
+ const expectation = CATEGORY_EXPECTATIONS[category];
390
+ if (expectation) {
391
+ // Union-based categories surface a single, actionable message
392
+ // (naming the token, the accepted shapes, and the offending
393
+ // value) instead of one "Invalid input" issue per union member.
394
+ //
395
+ // 2026-07-28: listing accepted shapes was not enough. A model wrote
396
+ // `motion` as the CSS shorthand `"180ms ease"` — idiomatic in a
397
+ // `transition:` declaration, but two design tokens in one string. The
398
+ // message enumerated the legal forms without naming the actual
399
+ // mistake, so the fix took three attempts (and, because gated
400
+ // proposals were validated only after approval, three operator
401
+ // clicks). Naming the likely mistake turns that into one attempt.
402
+ ctx.addIssue({
403
+ code: "custom",
404
+ path: [...path],
405
+ message: `${category} token "${String(name)}" invalid: expected ${expectation}; got ${describeValue(obj.$value)}` +
406
+ shorthandHint(category, obj.$value),
407
+ });
408
+ }
409
+ else {
410
+ for (const issue of sub.error.issues) {
411
+ ctx.addIssue({
412
+ code: "custom",
413
+ path: [...path, ...issue.path],
414
+ message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(obj.$value)})`,
415
+ });
416
+ }
417
+ }
418
+ }
419
+ return;
420
+ }
421
+ for (const [k, v] of Object.entries(obj)) {
422
+ if (k.startsWith("$"))
423
+ continue;
424
+ refineKnownCategoryLeaves(category, schema, v, [...path, k], ctx);
425
+ }
426
+ }
427
+ /**
428
+ * Top-level DTCG document. Known category keys:
429
+ *
430
+ * color, typography, spacing, radius, shadow, motion (duration +
431
+ * cubicBezier sub-groups), breakpoint, gradient (issue #153 — CSS
432
+ * gradient strings emitted as `--gradient-<name>`).
433
+ *
434
+ * Unknown root keys are tolerated because DTCG explicitly leaves the
435
+ * category vocabulary open — we don't want to reject a future
436
+ * `effect` category that operators bring from Figma.
437
+ * Root-level `$description` / `$extensions` are also tolerated (DTCG
438
+ * documents routinely carry these — the seeded default theme and
439
+ * Figma/Tokens Studio exports all have a root `$description`).
440
+ */
441
+ export const themeDocument = tokenGroupSchema.superRefine((doc, ctx) => {
442
+ for (const [category, schema] of Object.entries(CATEGORY_TOKEN_SCHEMAS)) {
443
+ if (category in doc) {
444
+ refineKnownCategoryLeaves(category, schema, doc[category], [category], ctx);
445
+ }
446
+ }
447
+ });
448
+ /**
449
+ * Validate a tokens document. Returns the parsed tree on success;
450
+ * throws a ZodError on failure (caller wraps into the op's HandlerError
451
+ * shape per CLAUDE.md §4).
452
+ */
453
+ export function validateThemeTokens(tokens) {
454
+ return themeDocument.parse(tokens);
455
+ }
456
+ /**
457
+ * v0.11.1 (issue #76) — Zod schema for `propose_create_theme.overrides`.
458
+ *
459
+ * The base shape is a permissive `Record<string, unknown>` (same as
460
+ * v0.11.0) so loose names like `primaryColor` / `fontHeading` /
461
+ * `spacing-lg` continue to flow through `normalizeTokens`. The explicit
462
+ * `primaryColor` recognition (v0.11.1) is documented here — when set,
463
+ * the propose-create handler derives a `color.primary.{50..900}`
464
+ * OKLCh ramp from the value (each stop `_derived: true`) instead of
465
+ * landing a single `color.primary` leaf via the normalizer.
466
+ *
467
+ * Operator-supplied `color.primary.<stop>` paths in the same overrides
468
+ * map layer over the derived ramp (explicit-wins precedence).
469
+ */
470
+ export const createThemeOverridesSchema = z.record(z.string(), z.unknown());
471
+ /**
472
+ * v0.11.1 (issue #76) — extract the `primaryColor` sentinel from a
473
+ * loose-name overrides map. Returns `{primaryColor, rest}` where `rest`
474
+ * is the same map minus the `primaryColor` key. Used by the propose-
475
+ * create handler to split the ramp-seed off before normalizing the
476
+ * remaining loose names.
477
+ */
478
+ export function extractPrimaryColorSeed(overrides) {
479
+ if (!overrides)
480
+ return { primaryColor: undefined, rest: {} };
481
+ const { primaryColor, ...rest } = overrides;
482
+ return {
483
+ primaryColor: typeof primaryColor === "string" ? primaryColor : undefined,
484
+ rest,
485
+ };
486
+ }
487
+ // ────────────────────────────────────────────────────────────────────
488
+ // Helpers
489
+ // ────────────────────────────────────────────────────────────────────
490
+ /**
491
+ * Walk a tokens document and produce a flat list of `(path, token)`
492
+ * pairs. Used by the renderer + alias resolver + summary formatter.
493
+ * Paths use dot-notation: `color.primary`, `typography.heading`,
494
+ * `color.brand.primary`, ...
495
+ */
496
+ export function flattenTokens(tokens) {
497
+ const out = [];
498
+ walk(tokens, [], out);
499
+ return out;
500
+ }
501
+ function walk(node, prefix, out) {
502
+ if (node === null || typeof node !== "object")
503
+ return;
504
+ const obj = node;
505
+ // Leaf: carries `$value`.
506
+ if ("$value" in obj) {
507
+ out.push({ path: prefix.join("."), token: obj });
508
+ return;
509
+ }
510
+ // Group: recurse.
511
+ for (const [k, v] of Object.entries(obj)) {
512
+ if (k.startsWith("$"))
513
+ continue; // tolerate DTCG group-level metadata
514
+ walk(v, [...prefix, k], out);
515
+ }
516
+ }
517
+ /**
518
+ * Build a one-line category summary for the system-prompt `## Theme`
519
+ * block ("8 colors, 5 typography, 6 spacing, 5 radii, 3 shadows").
520
+ */
521
+ export function summarizeTokens(tokens) {
522
+ const counts = new Map();
523
+ for (const { path } of flattenTokens(tokens)) {
524
+ const category = path.split(".")[0] ?? "(uncategorised)";
525
+ counts.set(category, (counts.get(category) ?? 0) + 1);
526
+ }
527
+ if (counts.size === 0)
528
+ return "no tokens";
529
+ const parts = [];
530
+ // Stable order so cached system prompts hit.
531
+ for (const k of [
532
+ "color",
533
+ "typography",
534
+ "spacing",
535
+ "radius",
536
+ "shadow",
537
+ "duration",
538
+ "ease",
539
+ "breakpoint",
540
+ ]) {
541
+ const n = counts.get(k);
542
+ if (n)
543
+ parts.push(`${n} ${pluralise(k, n)}`);
544
+ }
545
+ for (const [k, n] of counts) {
546
+ if (![
547
+ "color",
548
+ "typography",
549
+ "spacing",
550
+ "radius",
551
+ "shadow",
552
+ "duration",
553
+ "ease",
554
+ "breakpoint",
555
+ ].includes(k)) {
556
+ parts.push(`${n} ${k}`);
557
+ }
558
+ }
559
+ return parts.join(", ");
560
+ }
561
+ function pluralise(category, n) {
562
+ if (n === 1)
563
+ return category;
564
+ if (category === "radius")
565
+ return "radii";
566
+ if (category.endsWith("y"))
567
+ return `${category.slice(0, -1)}ies`;
568
+ return `${category}s`;
569
+ }
570
+ // ────────────────────────────────────────────────────────────────────
571
+ // Patch helpers (shared between themes.update_tokens + themes.execute_proposal)
572
+ // ────────────────────────────────────────────────────────────────────
573
+ /**
574
+ * v0.11.0 (#45, step-11 opt §5) — apply a canonical-path → value patch
575
+ * to a DTCG document. Returns a fresh document; the input is not
576
+ * mutated.
577
+ *
578
+ * Each entry in `writes` becomes a `{$value, $type}` leaf at the dotted
579
+ * canonical path. The `types` map carries the inferred DTCG `$type` for
580
+ * each path (already known to the normalizer) so the leaf advertises
581
+ * the right shape to consumers.
582
+ *
583
+ * Both themes.update_tokens (loose-input set path) and
584
+ * themes.propose_create's execute branch (tokens + overrides merge)
585
+ * use this same logic — extracted so the dotted-path merge lives in
586
+ * one place and v0.11.1's OKLCH auto-ramp can extend it without
587
+ * forking.
588
+ */
589
+ export function applyDtcgWrites(current, writes, types) {
590
+ const out = JSON.parse(JSON.stringify(current));
591
+ for (const [path, value] of Object.entries(writes)) {
592
+ const inferredType = types[path];
593
+ // v0.11.0 fix (#45 review thread on theme-normalize.ts:149) — when
594
+ // the incoming value is a partial composite object (e.g. typography's
595
+ // `{fontFamily: "Inter"}` from a loose `fontHeading` input), MERGE
596
+ // it into any existing leaf's `$value` instead of overwriting. This
597
+ // is the only sensible semantics for typography: setting fontFamily
598
+ // must not erase fontSize / fontWeight / etc. set previously.
599
+ const existing = readLeafAtPath(out, path);
600
+ let nextValue = value;
601
+ if (existing &&
602
+ typeof existing === "object" &&
603
+ typeof existing.$value === "object" &&
604
+ existing.$value !== null &&
605
+ typeof value === "object" &&
606
+ value !== null &&
607
+ !Array.isArray(value)) {
608
+ nextValue = {
609
+ ...existing.$value,
610
+ ...value,
611
+ };
612
+ }
613
+ setLeafAtPath(out, path, {
614
+ $value: nextValue,
615
+ ...(inferredType ? { $type: inferredType } : {}),
616
+ });
617
+ }
618
+ return out;
619
+ }
620
+ /**
621
+ * Read the existing leaf at a dotted DTCG path, returning the node if
622
+ * it already carries `$value` (i.e. is a token leaf) and `undefined`
623
+ * otherwise. Used by `applyDtcgWrites` to detect composite leaves
624
+ * where the new value should MERGE into the existing `$value` instead
625
+ * of replacing it.
626
+ */
627
+ function readLeafAtPath(doc, path) {
628
+ const parts = path.split(".");
629
+ let cur = doc;
630
+ for (const k of parts) {
631
+ if (!k)
632
+ return undefined;
633
+ if (cur === null || typeof cur !== "object")
634
+ return undefined;
635
+ cur = cur[k];
636
+ }
637
+ if (cur && typeof cur === "object" && "$value" in cur) {
638
+ return cur;
639
+ }
640
+ return undefined;
641
+ }
642
+ /**
643
+ * Walk a dotted DTCG path and set the leaf in-place. Caller passes a
644
+ * cloned document (never the original) — `applyDtcgWrites` enforces
645
+ * that for the public surface.
646
+ */
647
+ function setLeafAtPath(doc, path, leaf) {
648
+ const parts = path.split(".");
649
+ // The dotted path comes from the caller's `writes` map (form / AI input),
650
+ // so a segment like `__proto__` or `constructor` would let the walk below
651
+ // (`cur[k] = {}`, `cur[last] = leaf`) pollute the prototype chain (CodeQL
652
+ // js/prototype-pollution-utility). Reject the whole write on any unsafe
653
+ // segment rather than partially applying it.
654
+ if (parts.some(isUnsafeKey))
655
+ return;
656
+ let cur = doc;
657
+ for (let i = 0; i < parts.length - 1; i++) {
658
+ const k = parts[i];
659
+ if (!k)
660
+ continue;
661
+ if (cur[k] === undefined || cur[k] === null || typeof cur[k] !== "object") {
662
+ cur[k] = {};
663
+ }
664
+ cur = cur[k];
665
+ }
666
+ const last = parts[parts.length - 1];
667
+ if (last)
668
+ cur[last] = leaf;
669
+ }
670
+ /**
671
+ * Companion to `applyDtcgWrites` — drop the leaf at a canonical path
672
+ * if it exists. Returns `{tokens, removed}` so the caller can report
673
+ * which paths were actually removed (silent on missing paths so a
674
+ * remove-list that includes already-absent keys is idempotent).
675
+ */
676
+ export function removeDtcgPath(doc, path) {
677
+ const out = JSON.parse(JSON.stringify(doc));
678
+ const parts = path.split(".");
679
+ let cur = out;
680
+ for (let i = 0; i < parts.length - 1; i++) {
681
+ const k = parts[i];
682
+ if (!k)
683
+ continue;
684
+ const next = cur[k];
685
+ if (!next || typeof next !== "object")
686
+ return { tokens: doc, removed: false };
687
+ cur = next;
688
+ }
689
+ const last = parts[parts.length - 1];
690
+ if (!last)
691
+ return { tokens: doc, removed: false };
692
+ if (!(last in cur))
693
+ return { tokens: doc, removed: false };
694
+ delete cur[last];
695
+ return { tokens: out, removed: true };
696
+ }
697
+ //# sourceMappingURL=themes.js.map