@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,187 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.1 (issue #76) — OKLCh primary-color ramp generator.
5
+ *
6
+ * Given a seed color, derive a 10-stop ramp (`50`,`100`,…,`900`) by
7
+ * walking lightness linearly outward from the seed while preserving its
8
+ * chroma + hue. Anchored so the `500` stop equals the seed.
9
+ *
10
+ * Returns a DTCG group ready to merge at `color.primary`: each stop is
11
+ * a token leaf carrying `$value: "oklch(L C H)"` + `$type: "color"` +
12
+ * `_derived: true` annotation, plus a DTCG alias token at `DEFAULT`
13
+ * pointing at `{color.primary.500}` so existing `--color-primary`
14
+ * references continue to resolve via the v0.11.0 alias resolver.
15
+ *
16
+ * Simplest-possible math (Material 3 inspiration, OKLCh execution); the
17
+ * issue explicitly calls out HCT as future-tuning, not current scope.
18
+ */
19
+
20
+ import Color from "colorjs.io";
21
+ import type { ThemeDocument } from "./themes.js";
22
+
23
+ /**
24
+ * v0.11.1 — failure surface for the OKLCh ramp generator. Carries the
25
+ * supported color input formats so the AI's next turn can fix the
26
+ * input without round-tripping (CLAUDE.md §11 AI-actionable errors).
27
+ */
28
+ export class InvalidSeedColor extends Error {
29
+ readonly kind = "InvalidSeedColor" as const;
30
+ readonly input: string;
31
+ readonly supportedFormats: readonly string[];
32
+ constructor(input: string) {
33
+ const formats = [
34
+ "#rrggbb",
35
+ "#rgb",
36
+ "oklch(L C H)",
37
+ "rgb(r g b)",
38
+ "hsl(h s% l%)",
39
+ "lab(L a b)",
40
+ "lch(L C H)",
41
+ ] as const;
42
+ super(
43
+ `seed color '${input}' is not a recognised CSS color — pass one of: ${formats.join(", ")}.`,
44
+ );
45
+ this.name = "InvalidSeedColor";
46
+ this.input = input;
47
+ this.supportedFormats = formats;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Linearly-spaced lightness values for the 10 stops. The `500` slot is
53
+ * intentionally a placeholder — the seed's measured lightness replaces
54
+ * it at runtime so the brand color shows up unmodified at its anchor.
55
+ *
56
+ * Curve picked to match Tailwind's perceptual feel: light tints at
57
+ * 50/100 stay near-white, deep shades at 800/900 stay near-black.
58
+ */
59
+ const RAMP_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
60
+ const RAMP_LIGHTNESS = [0.97, 0.94, 0.87, 0.78, 0.67, 0.55, 0.45, 0.35, 0.25, 0.18] as const;
61
+ const SEED_INDEX = RAMP_KEYS.indexOf("500");
62
+
63
+ /**
64
+ * One ramp stop as a DTCG token leaf. The `_derived: true` annotation
65
+ * is a Caelo extension (sits under DTCG's open `$extensions`-style
66
+ * tolerance) so the admin UI can mark derived stops visually and the
67
+ * operator's explicit overrides can layer cleanly on top.
68
+ */
69
+ export interface DerivedRampStop {
70
+ readonly $value: string;
71
+ readonly $type: "color";
72
+ readonly _derived: true;
73
+ }
74
+
75
+ /**
76
+ * Return shape: a DTCG group (`Record<string, DerivedRampStop | alias>`)
77
+ * intended to be merged at `color.primary` plus the list of canonical
78
+ * paths the generator wrote (for echo-back in the propose preview).
79
+ */
80
+ export interface RampResult {
81
+ readonly tokens: Record<
82
+ string,
83
+ DerivedRampStop | { $value: string; $type: "color"; _derived: true }
84
+ >;
85
+ readonly derivedPaths: readonly string[];
86
+ }
87
+
88
+ /**
89
+ * Derive a 10-stop OKLCh primary-color ramp from a seed color.
90
+ *
91
+ * @param seedColor — any CSS color string. The seed's chroma + hue are
92
+ * preserved; lightness shifts to each ramp stop.
93
+ * @returns A DTCG group ready to write at `color.primary`.
94
+ * @throws InvalidSeedColor when the input doesn't parse as a CSS color.
95
+ */
96
+ export function deriveOklchPrimaryRamp(seedColor: string): RampResult {
97
+ if (typeof seedColor !== "string" || seedColor.trim().length === 0) {
98
+ throw new InvalidSeedColor(String(seedColor));
99
+ }
100
+ let seed: Color;
101
+ try {
102
+ seed = new Color(seedColor);
103
+ } catch (_e) {
104
+ throw new InvalidSeedColor(seedColor);
105
+ }
106
+ // Convert to OKLCh; the conversion is lossless within colorjs.io's
107
+ // gamut model (clamps to displayable sRGB on output).
108
+ const oklch = seed.to("oklch");
109
+ const coords = oklch.coords as [number, number, number]; // [L, C, H]
110
+ const seedL = coords[0];
111
+ const chroma = Number.isFinite(coords[1]) ? coords[1] : 0;
112
+ // Achromatic colors (#888, white, black) have NaN hue — preserve 0
113
+ // so the OKLCh string parses; chroma is 0 anyway.
114
+ const hue = Number.isFinite(coords[2]) ? coords[2] : 0;
115
+
116
+ const tokens: RampResult["tokens"] = {};
117
+ const derivedPaths: string[] = [];
118
+ for (let i = 0; i < RAMP_KEYS.length; i += 1) {
119
+ const key = RAMP_KEYS[i] as (typeof RAMP_KEYS)[number];
120
+ // Anchor the seed's lightness at the 500 stop; the rest follow the
121
+ // pre-computed curve. Clamp to [0.001, 0.999] so OKLCh emits a
122
+ // valid color (pure black/white can clip chroma in edge cases).
123
+ const rawL = i === SEED_INDEX ? seedL : (RAMP_LIGHTNESS[i] as number);
124
+ const L = Math.max(0.001, Math.min(0.999, rawL));
125
+ const value = formatOklch(L, chroma, hue);
126
+ tokens[key] = { $value: value, $type: "color", _derived: true };
127
+ derivedPaths.push(`color.primary.${key}`);
128
+ }
129
+ // DTCG alias so callers / CSS-vars that reference `color.primary`
130
+ // continue to resolve to the 500 stop. The renderer's alias resolver
131
+ // (v0.11.0) handles the indirection; no renderer changes needed.
132
+ tokens.DEFAULT = {
133
+ $value: "{color.primary.500}",
134
+ $type: "color",
135
+ _derived: true,
136
+ };
137
+ derivedPaths.push("color.primary.DEFAULT");
138
+
139
+ return { tokens, derivedPaths };
140
+ }
141
+
142
+ /**
143
+ * Merge a ramp result into a tokens document at `color.primary`,
144
+ * preserving any operator-supplied explicit stops in `overrides`
145
+ * (`{"color.primary.500": "#ff0000"}` keeps the explicit stop AND the
146
+ * derived ramp around it — explicit-wins precedence per Risk §6.2).
147
+ *
148
+ * Returns a fresh document; input is not mutated.
149
+ */
150
+ export function mergeRampIntoTokens(
151
+ baseTokens: ThemeDocument,
152
+ ramp: RampResult,
153
+ explicitOverrides: Record<string, unknown>,
154
+ ): ThemeDocument {
155
+ const out: ThemeDocument = JSON.parse(JSON.stringify(baseTokens));
156
+ const colorGroup = (out as Record<string, unknown>).color as Record<string, unknown> | undefined;
157
+ const nextColor: Record<string, unknown> = { ...(colorGroup ?? {}) };
158
+ // REPLACE the previous color.primary entry with the derived ramp
159
+ // group. The replacement is total — a leaf `{"$value": "…"}` becomes
160
+ // a group `{50: {…}, 100: {…}, …, DEFAULT: {…}}`. Other color tokens
161
+ // (foreground, secondary, etc.) are untouched.
162
+ nextColor.primary = { ...ramp.tokens };
163
+ (out as Record<string, unknown>).color = nextColor;
164
+
165
+ // Layer explicit operator-supplied `color.primary.<stop>` overrides
166
+ // on top of the derived ramp. Only `color.primary.*` paths matter
167
+ // here; other overrides flow through the existing applyDtcgWrites
168
+ // call upstream.
169
+ for (const [path, value] of Object.entries(explicitOverrides)) {
170
+ if (!path.startsWith("color.primary.")) continue;
171
+ const stopKey = path.slice("color.primary.".length);
172
+ if (!stopKey || stopKey.includes(".")) continue;
173
+ const primary = (out as { color: { primary: Record<string, unknown> } }).color.primary;
174
+ primary[stopKey] = {
175
+ $value: value,
176
+ $type: "color",
177
+ };
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function formatOklch(L: number, C: number, H: number): string {
183
+ // Three decimal places — enough for visual fidelity (OKLCh's perceptual
184
+ // step is ~0.005L), small enough for cache-friendly stable bytes.
185
+ const fmt = (n: number): string => n.toFixed(3).replace(/\.?0+$/, "") || "0";
186
+ return `oklch(${fmt(L)} ${fmt(C)} ${fmt(H)})`;
187
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Regression test for the CSS-token sanitizer (issue #113, S6 —
5
+ * js/incomplete-multi-character-sanitization + js/bad-tag-filter). A
6
+ * free-form typography fontFamily value must not be able to break out of
7
+ * the surrounding <style> block: `</style`, `<!--`, `-->`, and the HTML5
8
+ * alternative comment-close `--!>` are all scrubbed, idempotently.
9
+ */
10
+
11
+ import { describe, expect, it } from "bun:test";
12
+ import { renderThemeCss } from "./theme-render.js";
13
+ import type { ThemeDocument } from "./themes.js";
14
+
15
+ function fontDoc(fontFamily: string): ThemeDocument {
16
+ return {
17
+ typography: { body: { $value: { fontFamily }, $type: "typography" } },
18
+ } as unknown as ThemeDocument;
19
+ }
20
+
21
+ describe("renderThemeCss CSS-token sanitization (S6)", () => {
22
+ it("scrubs a </style> breakout from a fontFamily value", () => {
23
+ const css = renderThemeCss(fontDoc("Inter</style><script>alert(1)</script>"));
24
+ expect(css.toLowerCase()).not.toContain("</style");
25
+ });
26
+
27
+ it("scrubs CDO/CDC comment tokens including the --!> variant", () => {
28
+ const css = renderThemeCss(fontDoc("a<!--b-->c--!>d"));
29
+ expect(css).not.toContain("<!--");
30
+ expect(css).not.toContain("-->");
31
+ expect(css).not.toContain("--!>");
32
+ });
33
+
34
+ it("handles a re-creation fixture (sequential removal can't re-expose a token)", () => {
35
+ const css = renderThemeCss(fontDoc("<!--</style>-->"));
36
+ expect(css.toLowerCase()).not.toContain("</style");
37
+ expect(css).not.toContain("<!--");
38
+ expect(css).not.toContain("-->");
39
+ });
40
+
41
+ it("leaves a normal fontFamily untouched", () => {
42
+ const css = renderThemeCss(fontDoc("Inter, sans-serif"));
43
+ expect(css).toContain("Inter, sans-serif");
44
+ });
45
+ });
@@ -0,0 +1,119 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * v0.11.4 (issue #76 follow-up) — unit tests for `listThemeCssVarNames`.
5
+ *
6
+ * The function is the load-bearing fix for the CSS-var-invention bug:
7
+ * without an authoritative list of the names the renderer emits, the
8
+ * AI guesses names that don't exist (`--color-text`, `--color-surface`)
9
+ * and its module CSS falls through to hardcoded fallbacks.
10
+ */
11
+
12
+ import { describe, expect, it } from "bun:test";
13
+ import { listThemeCssVarNames, renderThemeCss } from "./theme-render.js";
14
+ import type { ThemeDocument } from "./themes.js";
15
+
16
+ describe("listThemeCssVarNames (v0.11.4)", () => {
17
+ it("returns empty array for an empty document", () => {
18
+ expect(listThemeCssVarNames({} as ThemeDocument)).toEqual([]);
19
+ });
20
+
21
+ it("lists every var name the renderer would emit for shadcn-default colors", () => {
22
+ const tokens: ThemeDocument = {
23
+ color: {
24
+ background: { $type: "color", $value: "#ffffff" },
25
+ foreground: { $type: "color", $value: "#0a0a0a" },
26
+ primary: { $type: "color", $value: "#171717" },
27
+ "muted-foreground": { $type: "color", $value: "#737373" },
28
+ },
29
+ } as ThemeDocument;
30
+ const names = listThemeCssVarNames(tokens);
31
+ expect(names).toContain("--color-background");
32
+ expect(names).toContain("--color-foreground");
33
+ expect(names).toContain("--color-primary");
34
+ expect(names).toContain("--color-muted-foreground");
35
+ // The set is exactly the four we defined — no extras.
36
+ expect(names.length).toBe(4);
37
+ });
38
+
39
+ it("emits typography sub-fields with the renderer's naming convention", () => {
40
+ const tokens: ThemeDocument = {
41
+ typography: {
42
+ body: {
43
+ $type: "typography",
44
+ $value: {
45
+ fontFamily: "system-ui",
46
+ fontSize: "1rem",
47
+ fontWeight: 400,
48
+ lineHeight: 1.5,
49
+ },
50
+ },
51
+ },
52
+ } as ThemeDocument;
53
+ const names = listThemeCssVarNames(tokens);
54
+ // The renderer maps typography sub-fields to category-prefixed vars:
55
+ // fontFamily → --font-<name>
56
+ // fontSize → --text-<name>
57
+ // fontWeight → --font-weight-<name>
58
+ // lineHeight → --leading-<name>
59
+ expect(names).toContain("--font-body");
60
+ expect(names).toContain("--text-body");
61
+ expect(names).toContain("--font-weight-body");
62
+ expect(names).toContain("--leading-body");
63
+ });
64
+
65
+ it("handles the DEFAULT-alias group-suffix shape (ramp leaves)", () => {
66
+ const tokens: ThemeDocument = {
67
+ color: {
68
+ primary: {
69
+ "50": { $type: "color", $value: "#eef2ff" },
70
+ "500": { $type: "color", $value: "#6366f1" },
71
+ DEFAULT: { $type: "color", $value: "#4f46e5" },
72
+ },
73
+ },
74
+ } as ThemeDocument;
75
+ const names = listThemeCssVarNames(tokens);
76
+ // DEFAULT → bare `--color-primary` (NOT `--color-primary-DEFAULT`).
77
+ expect(names).toContain("--color-primary");
78
+ expect(names).toContain("--color-primary-50");
79
+ expect(names).toContain("--color-primary-500");
80
+ expect(names).not.toContain("--color-primary-DEFAULT");
81
+ });
82
+
83
+ it("the returned names match the names the actual renderer emits", () => {
84
+ // Regression guard: the helper's output MUST stay in sync with
85
+ // `renderThemeCss` — they share the same `emitTokenLines` path,
86
+ // but a future change to one without the other would silently
87
+ // mislead the AI.
88
+ const tokens: ThemeDocument = {
89
+ color: {
90
+ primary: { $type: "color", $value: "#4f46e5" },
91
+ accent: { $type: "color", $value: "#ec4899" },
92
+ },
93
+ spacing: {
94
+ md: { $type: "dimension", $value: "1rem" },
95
+ },
96
+ radius: {
97
+ sm: { $type: "dimension", $value: "0.25rem" },
98
+ },
99
+ } as ThemeDocument;
100
+ const css = renderThemeCss(tokens);
101
+ const namesFromCss = new Set<string>();
102
+ for (const m of css.matchAll(/--[a-z0-9-]+/g)) namesFromCss.add(m[0]);
103
+ const namesFromHelper = new Set(listThemeCssVarNames(tokens));
104
+ expect(namesFromHelper).toEqual(namesFromCss);
105
+ });
106
+
107
+ it("returns names sorted (deterministic order for cache stability)", () => {
108
+ const tokens: ThemeDocument = {
109
+ color: {
110
+ primary: { $type: "color", $value: "#000" },
111
+ accent: { $type: "color", $value: "#fff" },
112
+ muted: { $type: "color", $value: "#999" },
113
+ },
114
+ } as ThemeDocument;
115
+ const names = listThemeCssVarNames(tokens);
116
+ const sorted = [...names].sort();
117
+ expect(names).toEqual(sorted);
118
+ });
119
+ });