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