@profpowell/vb-design-system 0.1.0

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/dist/vb-design-system.css +993 -0
  4. package/dist/vb-design-system.js +4482 -0
  5. package/dist/vb-design-system.min.css +1 -0
  6. package/dist/vb-design-system.min.js +513 -0
  7. package/package.json +78 -0
  8. package/src/index.css +21 -0
  9. package/src/index.js +24 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/vb-element.js +96 -0
  12. package/src/patterns/do-dont.css +86 -0
  13. package/src/patterns/token-table.css +67 -0
  14. package/src/utils/copy-init.js +287 -0
  15. package/src/web-components/accessibility-specimen/api.json +51 -0
  16. package/src/web-components/accessibility-specimen/logic.js +232 -0
  17. package/src/web-components/accessibility-specimen/static.html +41 -0
  18. package/src/web-components/accessibility-specimen/styles.css +156 -0
  19. package/src/web-components/brand-specimen/api.json +37 -0
  20. package/src/web-components/brand-specimen/logic.js +61 -0
  21. package/src/web-components/brand-specimen/static.html +77 -0
  22. package/src/web-components/brand-specimen/styles.css +10 -0
  23. package/src/web-components/breakpoint-specimen/api.json +44 -0
  24. package/src/web-components/breakpoint-specimen/logic.js +186 -0
  25. package/src/web-components/breakpoint-specimen/static.html +38 -0
  26. package/src/web-components/breakpoint-specimen/styles.css +180 -0
  27. package/src/web-components/color-palette/api.json +73 -0
  28. package/src/web-components/color-palette/logic.js +280 -0
  29. package/src/web-components/color-palette/static.html +54 -0
  30. package/src/web-components/color-palette/styles.css +113 -0
  31. package/src/web-components/color-picker/_color-utils.js +210 -0
  32. package/src/web-components/component-sampler/api.json +15 -0
  33. package/src/web-components/component-sampler/logic.js +120 -0
  34. package/src/web-components/component-sampler/static.html +47 -0
  35. package/src/web-components/component-sampler/styles.css +9 -0
  36. package/src/web-components/font-pairer/_font-data.js +95 -0
  37. package/src/web-components/font-pairer/api.json +56 -0
  38. package/src/web-components/font-pairer/logic.js +401 -0
  39. package/src/web-components/font-pairer/static.html +9 -0
  40. package/src/web-components/font-pairer/styles.css +10 -0
  41. package/src/web-components/gradient-builder/_gradient-utils.js +54 -0
  42. package/src/web-components/gradient-builder/api.json +63 -0
  43. package/src/web-components/gradient-builder/logic.js +261 -0
  44. package/src/web-components/gradient-builder/static.html +9 -0
  45. package/src/web-components/gradient-builder/styles.css +10 -0
  46. package/src/web-components/icon-set/api.json +39 -0
  47. package/src/web-components/icon-set/logic.js +133 -0
  48. package/src/web-components/icon-set/static.html +36 -0
  49. package/src/web-components/icon-set/styles.css +82 -0
  50. package/src/web-components/icon-specimen/api.json +46 -0
  51. package/src/web-components/icon-specimen/logic.js +67 -0
  52. package/src/web-components/icon-specimen/static.html +39 -0
  53. package/src/web-components/icon-specimen/styles.css +38 -0
  54. package/src/web-components/layout-specimen/api.json +12 -0
  55. package/src/web-components/layout-specimen/logic.js +219 -0
  56. package/src/web-components/layout-specimen/static.html +22 -0
  57. package/src/web-components/layout-specimen/styles.css +95 -0
  58. package/src/web-components/motion-specimen/api.json +54 -0
  59. package/src/web-components/motion-specimen/logic.js +146 -0
  60. package/src/web-components/motion-specimen/static.html +13 -0
  61. package/src/web-components/motion-specimen/styles.css +29 -0
  62. package/src/web-components/palette-generator/_palette-utils.js +133 -0
  63. package/src/web-components/palette-generator/api.json +79 -0
  64. package/src/web-components/palette-generator/logic.js +288 -0
  65. package/src/web-components/palette-generator/static.html +47 -0
  66. package/src/web-components/palette-generator/styles.css +36 -0
  67. package/src/web-components/semantic-palette/_color-math.js +149 -0
  68. package/src/web-components/semantic-palette/api.json +41 -0
  69. package/src/web-components/semantic-palette/logic.js +400 -0
  70. package/src/web-components/semantic-palette/static.html +42 -0
  71. package/src/web-components/semantic-palette/styles.css +20 -0
  72. package/src/web-components/spacing-specimen/api.json +63 -0
  73. package/src/web-components/spacing-specimen/logic.js +132 -0
  74. package/src/web-components/spacing-specimen/static.html +71 -0
  75. package/src/web-components/spacing-specimen/styles.css +58 -0
  76. package/src/web-components/theme-catalog/api.json +14 -0
  77. package/src/web-components/theme-catalog/logic.js +228 -0
  78. package/src/web-components/theme-catalog/static.html +15 -0
  79. package/src/web-components/theme-export/api.json +18 -0
  80. package/src/web-components/theme-export/dtcg-serialize.js +417 -0
  81. package/src/web-components/theme-export/logic.js +201 -0
  82. package/src/web-components/theme-export/static.html +12 -0
  83. package/src/web-components/theme-export/theme-css-parse.js +93 -0
  84. package/src/web-components/theme-import/api.json +14 -0
  85. package/src/web-components/theme-import/catalog/atlassian.tokens.json +303 -0
  86. package/src/web-components/theme-import/catalog/bootstrap.tokens.json +304 -0
  87. package/src/web-components/theme-import/catalog/carbon.tokens.json +301 -0
  88. package/src/web-components/theme-import/catalog/catppuccin-mocha.tokens.json +301 -0
  89. package/src/web-components/theme-import/catalog/govuk.tokens.json +301 -0
  90. package/src/web-components/theme-import/catalog/manifest.json +132 -0
  91. package/src/web-components/theme-import/catalog/material.tokens.json +289 -0
  92. package/src/web-components/theme-import/catalog/salesforce.tokens.json +301 -0
  93. package/src/web-components/theme-import/catalog/tailwind.tokens.json +302 -0
  94. package/src/web-components/theme-import/dtcg-deserialize.js +322 -0
  95. package/src/web-components/theme-import/logic.js +263 -0
  96. package/src/web-components/theme-import/static.html +15 -0
  97. package/src/web-components/token-specimen/api.json +24 -0
  98. package/src/web-components/token-specimen/logic.js +256 -0
  99. package/src/web-components/token-specimen/static.html +47 -0
  100. package/src/web-components/token-specimen/styles.css +10 -0
  101. package/src/web-components/type-specimen/api.json +91 -0
  102. package/src/web-components/type-specimen/logic.js +156 -0
  103. package/src/web-components/type-specimen/static.html +84 -0
  104. package/src/web-components/type-specimen/styles.css +131 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * DTCG (Design Tokens Community Group) serializer for VB token entries.
3
+ *
4
+ * Pure function over `Array<[name, value]>` where each entry is a CSS
5
+ * custom-property name + its declared value. Returns a stable-2025.10
6
+ * shaped DTCG object suitable for JSON.stringify.
7
+ *
8
+ * Used by <theme-export format="dtcg">. No DOM, no I/O — round-trip
9
+ * fidelity for VB-specific syntax (relative-color, light-dark()) is
10
+ * preserved under $extensions["com.vanilla-breeze"].
11
+ *
12
+ * Spec: https://www.designtokens.org/ (stable v1, 2025-10-28)
13
+ */
14
+
15
+ const VB_NS = 'com.vanilla-breeze';
16
+ const SPEC = '2025.10';
17
+
18
+ /**
19
+ * @typedef {[string, string]} Entry
20
+ * @typedef {{
21
+ * vbVersion?: string,
22
+ * }} SerializeOptions
23
+ */
24
+
25
+ /**
26
+ * @param {Entry[]} entries
27
+ * @param {SerializeOptions} [options]
28
+ */
29
+ export function serializeDTCG(entries, options = {}) {
30
+ /** @type {Record<string, any>} */
31
+ const root = {};
32
+ const unmapped = [];
33
+ let seedDerivation = false;
34
+
35
+ for (const [name, rawValue] of entries) {
36
+ const value = String(rawValue).trim();
37
+ const target = mapPrefix(name);
38
+ if (!target) {
39
+ unmapped.push(name);
40
+ continue;
41
+ }
42
+ if (target.path[0] === 'color' && target.path[1] === 'seeds') {
43
+ seedDerivation = true;
44
+ }
45
+ const token = buildToken(name, value, target);
46
+ insertAt(root, target.path, target.name, token);
47
+ }
48
+
49
+ /** @type {Record<string, any>} */
50
+ const meta = { spec: SPEC };
51
+ if (options.vbVersion) meta.vbVersion = options.vbVersion;
52
+ if (seedDerivation) meta.seedDerivation = true;
53
+ if (unmapped.length) meta.unmapped = unmapped;
54
+ root.$extensions = { [VB_NS]: meta };
55
+
56
+ return root;
57
+ }
58
+
59
+ /* ---------- prefix → group/type routing ---------- */
60
+
61
+ /**
62
+ * @typedef {{ path: string[], type: string, name: string }} Target
63
+ */
64
+
65
+ /**
66
+ * Return the DTCG group path + type for a VB CSS custom-property name.
67
+ * Order matters — more specific prefixes must come before broader ones.
68
+ *
69
+ * @param {string} name
70
+ * @returns {Target | null}
71
+ */
72
+ function mapPrefix(name) {
73
+ const prefixes = [
74
+ // color seeds (must precede --color-)
75
+ { p: '--hue-', path: ['color', 'seeds'], type: 'number', keep: true },
76
+ { p: '--lightness-', path: ['color', 'seeds'], type: 'number', keep: true },
77
+ { p: '--chroma-', path: ['color', 'seeds'], type: 'number', keep: true },
78
+ // color
79
+ { p: '--color-', path: ['color'], type: 'color' },
80
+ // typography
81
+ { p: '--font-size-', path: ['typography', 'size'], type: 'dimension' },
82
+ { p: '--font-weight-', path: ['typography', 'weight'], type: 'fontWeight' },
83
+ { p: '--font-', path: ['typography', 'family'], type: 'fontFamily' },
84
+ { p: '--line-height-', path: ['typography', 'lineHeight'], type: 'number' },
85
+ { p: '--letter-spacing-',path: ['typography', 'letterSpacing'], type: 'dimension' },
86
+ // spacing
87
+ { p: '--size-', path: ['spacing'], type: 'dimension' },
88
+ // border
89
+ { p: '--radius-', path: ['border', 'radius'], type: 'dimension' },
90
+ { p: '--border-width-', path: ['border', 'width'], type: 'dimension' },
91
+ // motion
92
+ { p: '--duration-', path: ['motion', 'duration'], type: 'duration' },
93
+ { p: '--ease-', path: ['motion', 'easing'], type: 'cubicBezier' },
94
+ // effect
95
+ { p: '--shadow-', path: ['effect', 'shadow'], type: 'shadow' },
96
+ ];
97
+ for (const { p, path, type, keep } of prefixes) {
98
+ if (name.startsWith(p)) {
99
+ // For seeds we keep the full prefix-stripped name (hue-primary, lightness-primary)
100
+ // so consumers can disambiguate without re-parsing.
101
+ const tail = keep ? name.slice(2) : name.slice(p.length);
102
+ return { path, type, name: tail };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /* ---------- token builders ---------- */
109
+
110
+ /**
111
+ * @param {string} _name
112
+ * @param {string} value
113
+ * @param {Target} target
114
+ */
115
+ function buildToken(_name, value, target) {
116
+ switch (target.type) {
117
+ case 'color': return buildColorToken(value);
118
+ case 'fontFamily': return { $type: 'fontFamily', $value: parseFontFamily(value) };
119
+ case 'fontWeight': return { $type: 'fontWeight', $value: parseNumber(value) };
120
+ case 'number': return buildNumberToken(value);
121
+ case 'dimension': return buildDimensionToken(value);
122
+ case 'duration': return { $type: 'duration', $value: parseDuration(value) };
123
+ case 'cubicBezier': return buildCubicBezierToken(value);
124
+ case 'shadow': return buildShadowToken(value);
125
+ default: return { $value: value };
126
+ }
127
+ }
128
+
129
+ /* ---------- color ---------- */
130
+
131
+ function buildColorToken(value) {
132
+ // light-dark(a, b) → variant triplet per stable spec.
133
+ const ld = parseLightDark(value);
134
+ if (ld) {
135
+ return {
136
+ $root: { $type: 'color', $value: parseColorValue(ld.light) ?? opaque(ld.light) },
137
+ light: { $type: 'color', $value: parseColorValue(ld.light) ?? opaque(ld.light) },
138
+ dark: { $type: 'color', $value: parseColorValue(ld.dark) ?? opaque(ld.dark) },
139
+ $extensions: { [VB_NS]: { lightDark: value } },
140
+ };
141
+ }
142
+ // oklch(from …) and other relative-color expressions are opaque.
143
+ if (/\bfrom\b/.test(value) && /^oklch\(/i.test(value)) {
144
+ return {
145
+ $type: 'color',
146
+ $value: { colorSpace: 'srgb', components: [0, 0, 0] },
147
+ $extensions: { [VB_NS]: { expression: value } },
148
+ };
149
+ }
150
+ const parsed = parseColorValue(value);
151
+ if (parsed) return { $type: 'color', $value: parsed };
152
+ return { $type: 'color', $value: { colorSpace: 'srgb', components: [0, 0, 0] }, $extensions: { [VB_NS]: { literal: value } } };
153
+ }
154
+
155
+ function opaque(value) {
156
+ return { colorSpace: 'srgb', components: [0, 0, 0], $vbLiteral: value };
157
+ }
158
+
159
+ /**
160
+ * Parse a single color literal into DTCG color $value, or null if unrecognized.
161
+ * Recognizes: oklch(L C H [/ A]), #rgb, #rrggbb, rgb(r,g,b), rgba(r,g,b,a).
162
+ */
163
+ function parseColorValue(raw) {
164
+ const value = String(raw).trim();
165
+ // OKLCH
166
+ let m = value.match(/^oklch\(\s*([^)]+)\)\s*$/i);
167
+ if (m) {
168
+ const [comps, alpha] = splitAlpha(m[1]);
169
+ const parts = comps.trim().split(/\s+/);
170
+ if (parts.length < 3) return null;
171
+ const L = parsePercentOrNumber(parts[0]);
172
+ const C = parseNumber(parts[1]);
173
+ const H = parseNumber(parts[2]);
174
+ if ([L, C, H].some(Number.isNaN)) return null;
175
+ /** @type {Record<string, any>} */
176
+ const out = {
177
+ colorSpace: 'oklch',
178
+ // OKLCH lightness is 0–1; VB writes 55%.
179
+ components: [normalizeLightness(parts[0], L), C, H],
180
+ };
181
+ if (alpha != null) out.alpha = alpha;
182
+ return out;
183
+ }
184
+ // hex
185
+ m = value.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
186
+ if (m) {
187
+ const hex = m[1];
188
+ let r, g, b, a;
189
+ if (hex.length === 3 || hex.length === 4) {
190
+ r = parseInt(hex[0] + hex[0], 16) / 255;
191
+ g = parseInt(hex[1] + hex[1], 16) / 255;
192
+ b = parseInt(hex[2] + hex[2], 16) / 255;
193
+ if (hex.length === 4) a = parseInt(hex[3] + hex[3], 16) / 255;
194
+ } else {
195
+ r = parseInt(hex.slice(0, 2), 16) / 255;
196
+ g = parseInt(hex.slice(2, 4), 16) / 255;
197
+ b = parseInt(hex.slice(4, 6), 16) / 255;
198
+ if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16) / 255;
199
+ }
200
+ /** @type {Record<string, any>} */
201
+ const out = {
202
+ colorSpace: 'srgb',
203
+ components: [r, g, b],
204
+ hex: '#' + (hex.length <= 4 ? hex.slice(0, 3).split('').map(c => c + c).join('') : hex.slice(0, 6)).toLowerCase(),
205
+ };
206
+ if (a != null) out.alpha = a;
207
+ return out;
208
+ }
209
+ // rgb()/rgba()
210
+ m = value.match(/^rgba?\(\s*([^)]+)\)\s*$/i);
211
+ if (m) {
212
+ const parts = m[1].split(/\s*,\s*|\s+/).filter(Boolean);
213
+ if (parts.length < 3) return null;
214
+ const r = parseRGBChannel(parts[0]);
215
+ const g = parseRGBChannel(parts[1]);
216
+ const b = parseRGBChannel(parts[2]);
217
+ /** @type {Record<string, any>} */
218
+ const out = { colorSpace: 'srgb', components: [r, g, b] };
219
+ if (parts[3] != null) out.alpha = parsePercentOrNumber(parts[3]);
220
+ return out;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function normalizeLightness(raw, parsed) {
226
+ // OKLCH lightness comes in two flavors: "55%" → 0.55, "0.55" → 0.55.
227
+ if (typeof raw === 'string' && raw.endsWith('%')) return parsed / 100;
228
+ return parsed;
229
+ }
230
+
231
+ function splitAlpha(inner) {
232
+ const idx = inner.indexOf('/');
233
+ if (idx < 0) return [inner, null];
234
+ const comps = inner.slice(0, idx);
235
+ const alpha = parsePercentOrNumber(inner.slice(idx + 1).trim());
236
+ return [comps, alpha];
237
+ }
238
+
239
+ function parseRGBChannel(s) {
240
+ const v = s.trim();
241
+ if (v.endsWith('%')) return parseFloat(v) / 100;
242
+ return parseFloat(v) / 255;
243
+ }
244
+
245
+ /* ---------- number / dimension ---------- */
246
+
247
+ function buildNumberToken(value) {
248
+ const n = parsePercentOrNumber(value);
249
+ /** @type {Record<string, any>} */
250
+ const tok = { $type: 'number', $value: n };
251
+ if (typeof value === 'string' && value.trim().endsWith('%')) {
252
+ tok.$extensions = { [VB_NS]: { unit: '%' } };
253
+ }
254
+ return tok;
255
+ }
256
+
257
+ function buildDimensionToken(value) {
258
+ const v = String(value).trim();
259
+ const m = v.match(/^(-?[\d.]+)(px|rem|em|%)?$/);
260
+ if (!m) return { $type: 'dimension', $value: { value: 0, unit: 'px' }, $extensions: { [VB_NS]: { literal: v } } };
261
+ const n = parseFloat(m[1]);
262
+ const unit = m[2] || 'px';
263
+ // DTCG only specs px and rem; em / % go through with an extension hint.
264
+ const dtcgUnit = (unit === 'px' || unit === 'rem') ? unit : 'rem';
265
+ /** @type {Record<string, any>} */
266
+ const tok = { $type: 'dimension', $value: { value: n, unit: dtcgUnit } };
267
+ if (unit !== 'px' && unit !== 'rem') {
268
+ tok.$extensions = { [VB_NS]: { unit } };
269
+ }
270
+ return tok;
271
+ }
272
+
273
+ function parseNumber(value) { return parseFloat(String(value).trim()); }
274
+ function parsePercentOrNumber(value) {
275
+ const v = String(value).trim();
276
+ if (v.endsWith('%')) return parseFloat(v);
277
+ return parseFloat(v);
278
+ }
279
+
280
+ /* ---------- font family ---------- */
281
+
282
+ function parseFontFamily(value) {
283
+ // Split on commas not inside quotes; strip surrounding quotes.
284
+ const out = [];
285
+ let buf = '';
286
+ let quote = null;
287
+ for (const ch of String(value)) {
288
+ if (quote) {
289
+ if (ch === quote) { quote = null; continue; }
290
+ buf += ch;
291
+ } else if (ch === '"' || ch === "'") {
292
+ quote = ch;
293
+ } else if (ch === ',') {
294
+ const t = buf.trim();
295
+ if (t) out.push(t);
296
+ buf = '';
297
+ } else {
298
+ buf += ch;
299
+ }
300
+ }
301
+ const t = buf.trim();
302
+ if (t) out.push(t);
303
+ return out;
304
+ }
305
+
306
+ /* ---------- duration / cubic-bezier ---------- */
307
+
308
+ function parseDuration(value) {
309
+ const v = String(value).trim();
310
+ let m = v.match(/^(-?[\d.]+)(ms|s)$/);
311
+ if (m) return { value: parseFloat(m[1]), unit: m[2] };
312
+ return { value: parseFloat(v), unit: 'ms' };
313
+ }
314
+
315
+ function buildCubicBezierToken(value) {
316
+ const m = String(value).match(/^cubic-bezier\(\s*([^)]+)\)\s*$/i);
317
+ if (!m) return { $type: 'cubicBezier', $value: [0, 0, 1, 1], $extensions: { [VB_NS]: { literal: value } } };
318
+ const parts = m[1].split(',').map(s => parseFloat(s.trim()));
319
+ if (parts.length !== 4 || parts.some(Number.isNaN)) {
320
+ return { $type: 'cubicBezier', $value: [0, 0, 1, 1], $extensions: { [VB_NS]: { literal: value } } };
321
+ }
322
+ return { $type: 'cubicBezier', $value: parts };
323
+ }
324
+
325
+ /* ---------- shadow ---------- */
326
+
327
+ function buildShadowToken(value) {
328
+ const stops = splitTopLevelCommas(String(value));
329
+ const parsed = stops.map(parseShadowStop);
330
+ if (parsed.some(p => p == null)) {
331
+ return { $type: 'shadow', $value: { offsetX: { value: 0, unit: 'px' }, offsetY: { value: 0, unit: 'px' }, blur: { value: 0, unit: 'px' }, color: { colorSpace: 'srgb', components: [0, 0, 0] } }, $extensions: { [VB_NS]: { literal: value } } };
332
+ }
333
+ if (parsed.length === 1) return { $type: 'shadow', $value: parsed[0] };
334
+ return { $type: 'shadow', $value: parsed };
335
+ }
336
+
337
+ /**
338
+ * Parse a single shadow stop "offsetX offsetY blur [spread] color" → DTCG shadow object.
339
+ * Returns null if the stop can't be parsed (color must be present).
340
+ */
341
+ function parseShadowStop(raw) {
342
+ const s = raw.trim();
343
+ // Pull out the color first — it can be hex, rgb(), rgba(), or oklch().
344
+ const colorMatch = s.match(/(#[0-9a-f]{3,8}|rgba?\([^)]+\)|oklch\([^)]+\))$/i);
345
+ if (!colorMatch) return null;
346
+ const color = parseColorValue(colorMatch[0]);
347
+ if (!color) return null;
348
+ const dimsRaw = s.slice(0, colorMatch.index).trim();
349
+ const dims = dimsRaw.split(/\s+/).filter(Boolean);
350
+ if (dims.length < 3) return null;
351
+ const dim = (i) => parseDimensionPart(dims[i]);
352
+ /** @type {Record<string, any>} */
353
+ const out = {
354
+ offsetX: dim(0),
355
+ offsetY: dim(1),
356
+ blur: dim(2),
357
+ color,
358
+ };
359
+ if (dims[3]) out.spread = dim(3);
360
+ return out;
361
+ }
362
+
363
+ function parseDimensionPart(part) {
364
+ const m = part.match(/^(-?[\d.]+)(px|rem|em)?$/);
365
+ if (!m) return { value: 0, unit: 'px' };
366
+ const n = parseFloat(m[1]);
367
+ const unit = m[2] || 'px';
368
+ const dtcgUnit = (unit === 'px' || unit === 'rem') ? unit : 'rem';
369
+ return { value: n, unit: dtcgUnit };
370
+ }
371
+
372
+ /**
373
+ * Split "a, b, c" on top-level commas — does not split commas inside parens.
374
+ */
375
+ function splitTopLevelCommas(value) {
376
+ const out = [];
377
+ let buf = '';
378
+ let depth = 0;
379
+ for (const ch of value) {
380
+ if (ch === '(') depth++;
381
+ else if (ch === ')') depth--;
382
+ if (ch === ',' && depth === 0) {
383
+ out.push(buf);
384
+ buf = '';
385
+ } else {
386
+ buf += ch;
387
+ }
388
+ }
389
+ if (buf.trim()) out.push(buf);
390
+ return out;
391
+ }
392
+
393
+ /* ---------- light-dark ---------- */
394
+
395
+ function parseLightDark(value) {
396
+ const m = String(value).match(/^light-dark\(\s*([\s\S]+)\s*\)\s*$/i);
397
+ if (!m) return null;
398
+ const parts = splitTopLevelCommas(m[1]);
399
+ if (parts.length !== 2) return null;
400
+ return { light: parts[0].trim(), dark: parts[1].trim() };
401
+ }
402
+
403
+ /* ---------- tree insertion ---------- */
404
+
405
+ /**
406
+ * Walk `root` along `path`, creating empty groups as needed, then place
407
+ * `token` under `name` at the leaf. Existing tokens with the same name
408
+ * are overwritten (last write wins).
409
+ */
410
+ function insertAt(root, path, name, token) {
411
+ let node = root;
412
+ for (const key of path) {
413
+ if (!node[key] || typeof node[key] !== 'object') node[key] = {};
414
+ node = node[key];
415
+ }
416
+ node[name] = token;
417
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * theme-export: Collect VB custom property overrides from a scope and emit them as CSS or JSON.
3
+ *
4
+ * The assembly glue for the Theme Composer: editable specimens write CSS
5
+ * custom properties on a scope (usually :root); this component scans those
6
+ * overrides and produces a ready-to-paste theme.css block.
7
+ *
8
+ * @attr {string} scope - CSS selector for the scope to read from (default: ":root")
9
+ * @attr {string} selector - CSS selector to emit in the output (default: ":root")
10
+ * @attr {string} include - Comma-separated prefixes to collect
11
+ * (default: "--color-,--hue-,--lightness-,--chroma-,--font-,--size-,--radius-,--shadow-,--border-width-,--ease-,--duration-")
12
+ * @attr {string} format - "css" (default), "json", or "dtcg" (Design Tokens Community Group, stable 2025.10)
13
+ * @attr {string} vb-version - Optional VB version string to include in DTCG $extensions metadata
14
+ * @attr {string} label - Optional heading label
15
+ * @attr {boolean} live - Re-scan whenever an editable specimen fires a :change event
16
+ *
17
+ * @fires theme-export:change - When the output is regenerated. detail: { output, format, tokens }
18
+ *
19
+ * @example
20
+ * <theme-export></theme-export>
21
+ * <theme-export scope="#preview" label="Your Theme" live></theme-export>
22
+ */
23
+ import { registerComponent } from '../../lib/bundle-registry.js';
24
+ import { VBElement } from '../../lib/vb-element.js';
25
+ import { copyText } from '../../utils/copy-init.js';
26
+ import { serializeDTCG } from './dtcg-serialize.js';
27
+
28
+ const DEFAULT_INCLUDE = '--color-,--hue-,--lightness-,--chroma-,--font-,--size-,--radius-,--shadow-,--border-width-,--ease-,--duration-,--line-height-,--letter-spacing-';
29
+
30
+ const LIVE_EVENTS = [
31
+ 'type-specimen:change',
32
+ 'spacing-specimen:change',
33
+ 'token-specimen:change',
34
+ 'semantic-palette:change',
35
+ ];
36
+
37
+ class ThemeExport extends VBElement {
38
+ static observedAttributes = ['scope', 'selector', 'include', 'format', 'label', 'live'];
39
+
40
+ setup() {
41
+ this.#render();
42
+ this.#refresh();
43
+ this.#wireButtons();
44
+ this.#wireLive();
45
+ }
46
+
47
+ attributeChangedCallback() {
48
+ if (this.isConnected && this.hasAttribute('data-upgraded')) {
49
+ this.#render();
50
+ this.#refresh();
51
+ this.#wireButtons();
52
+ this.#wireLive();
53
+ }
54
+ }
55
+
56
+ #scopeEl() {
57
+ const sel = this.getAttribute('scope') || ':root';
58
+ try {
59
+ return sel === ':root' ? document.documentElement : document.querySelector(sel);
60
+ } catch {
61
+ return document.documentElement;
62
+ }
63
+ }
64
+
65
+ #prefixes() {
66
+ const raw = this.getAttribute('include') || DEFAULT_INCLUDE;
67
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
68
+ }
69
+
70
+ #collect() {
71
+ const scope = this.#scopeEl();
72
+ if (!scope) return [];
73
+ const prefixes = this.#prefixes();
74
+ const style = /** @type {HTMLElement} */ (scope).style;
75
+ const entries = [];
76
+ for (let i = 0; i < style.length; i++) {
77
+ const name = style.item(i);
78
+ if (!name.startsWith('--')) continue;
79
+ if (!prefixes.some((p) => name.startsWith(p))) continue;
80
+ const value = style.getPropertyValue(name).trim();
81
+ if (value) entries.push([name, value]);
82
+ }
83
+ return entries;
84
+ }
85
+
86
+ #format(entries) {
87
+ const format = this.getAttribute('format') || 'css';
88
+ if (format === 'json') {
89
+ const obj = Object.fromEntries(entries);
90
+ return JSON.stringify(obj, null, 2);
91
+ }
92
+ if (format === 'dtcg') {
93
+ const vbVersion = this.getAttribute('vb-version') || undefined;
94
+ return JSON.stringify(serializeDTCG(entries, { vbVersion }), null, 2);
95
+ }
96
+ const selector = this.getAttribute('selector') || ':root';
97
+ if (!entries.length) {
98
+ return `${selector} {\n /* No theme overrides detected yet. Edit a specimen to populate. */\n}\n`;
99
+ }
100
+ const lines = entries.map(([name, value]) => ` ${name}: ${value};`).join('\n');
101
+ return `${selector} {\n${lines}\n}\n`;
102
+ }
103
+
104
+ #render() {
105
+ const label = this.getAttribute('label') || '';
106
+ const labelHTML = label ? `<p style="font-weight:600;margin:0 0 var(--size-xs,0.5rem)">${label}</p>` : '';
107
+ const toolbarStyle = `display:flex;gap:var(--size-2xs,0.375rem);margin-block-end:var(--size-xs,0.5rem);align-items:center;flex-wrap:wrap`;
108
+ const btn = `padding:0.375rem 0.75rem;border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);background:var(--color-surface,#fff);color:var(--color-text,#222);cursor:pointer;font:inherit;font-size:var(--font-size-xs,0.75rem)`;
109
+ const sel = `padding:0.375rem 0.5rem;border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);background:var(--color-surface,#fff);color:var(--color-text,#222);font:inherit;font-size:var(--font-size-xs,0.75rem)`;
110
+ const ta = `inline-size:100%;block-size:16rem;padding:var(--size-s,0.75rem);border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);background:var(--color-surface-sunken,#f8f8f8);color:var(--color-text,#222);font-family:var(--font-mono,monospace);font-size:var(--font-size-xs,0.75rem);white-space:pre;overflow:auto;tab-size:2;resize:vertical;box-sizing:border-box`;
111
+ const current = this.getAttribute('format') || 'css';
112
+
113
+ this.innerHTML = `
114
+ ${labelHTML}
115
+ <div class="te-toolbar" style="${toolbarStyle}">
116
+ <label style="display:flex;gap:var(--size-2xs,0.375rem);align-items:center;font-size:var(--font-size-xs,0.75rem);color:var(--color-text-muted,#666)">
117
+ Format
118
+ <select class="te-format" style="${sel}" aria-label="Output format">
119
+ <option value="css"${current === 'css' ? ' selected' : ''}>CSS</option>
120
+ <option value="json"${current === 'json' ? ' selected' : ''}>JSON</option>
121
+ <option value="dtcg"${current === 'dtcg' ? ' selected' : ''}>DTCG</option>
122
+ </select>
123
+ </label>
124
+ <button type="button" class="te-refresh" style="${btn}" aria-label="Refresh">Refresh</button>
125
+ <button type="button" class="te-copy" style="${btn}">Copy</button>
126
+ <button type="button" class="te-download" style="${btn}">Download</button>
127
+ <span class="te-count" style="align-self:center;font-size:var(--font-size-xs,0.75rem);color:var(--color-text-muted,#666);margin-inline-start:auto"></span>
128
+ </div>
129
+ <textarea class="te-output" readonly aria-label="Theme output" style="${ta}"></textarea>
130
+ `;
131
+ }
132
+
133
+ #refresh() {
134
+ const entries = this.#collect();
135
+ const output = this.#format(entries);
136
+ const ta = this.querySelector('.te-output');
137
+ const count = this.querySelector('.te-count');
138
+ if (ta) /** @type {HTMLTextAreaElement} */ (ta).value = output;
139
+ if (count) count.textContent = `${entries.length} token${entries.length === 1 ? '' : 's'}`;
140
+ this.dispatchEvent(new CustomEvent('theme-export:change', {
141
+ bubbles: true,
142
+ detail: { output, format: this.getAttribute('format') || 'css', tokens: Object.fromEntries(entries) },
143
+ }));
144
+ }
145
+
146
+ #wireButtons() {
147
+ const refresh = this.querySelector('.te-refresh');
148
+ const copy = this.querySelector('.te-copy');
149
+ const download = this.querySelector('.te-download');
150
+ const formatSel = /** @type {HTMLSelectElement|null} */ (this.querySelector('.te-format'));
151
+ if (formatSel) {
152
+ this.listen(formatSel, 'change', () => {
153
+ this.setAttribute('format', formatSel.value);
154
+ });
155
+ }
156
+ if (refresh) this.listen(refresh, 'click', () => this.#refresh());
157
+ if (copy) {
158
+ this.listen(copy, 'click', () => {
159
+ const ta = /** @type {HTMLTextAreaElement} */ (this.querySelector('.te-output'));
160
+ if (!ta) return;
161
+ copyText(ta.value, {
162
+ button: /** @type {HTMLElement} */ (copy),
163
+ announceMessage: 'Theme copied',
164
+ });
165
+ });
166
+ }
167
+ if (download) {
168
+ this.listen(download, 'click', () => this.#download());
169
+ }
170
+ }
171
+
172
+ #download() {
173
+ const ta = /** @type {HTMLTextAreaElement} */ (this.querySelector('.te-output'));
174
+ if (!ta) return;
175
+ const format = this.getAttribute('format') || 'css';
176
+ let ext = 'css';
177
+ let mime = 'text/css';
178
+ let filename = 'theme.css';
179
+ if (format === 'json') {
180
+ ext = 'json'; mime = 'application/json'; filename = 'theme.json';
181
+ } else if (format === 'dtcg') {
182
+ ext = 'tokens.json'; mime = 'application/design-tokens+json'; filename = 'theme.tokens.json';
183
+ }
184
+ const blob = new Blob([ta.value], { type: mime });
185
+ const url = URL.createObjectURL(blob);
186
+ const a = document.createElement('a');
187
+ a.href = url;
188
+ a.download = filename;
189
+ a.click();
190
+ URL.revokeObjectURL(url);
191
+ }
192
+
193
+ #wireLive() {
194
+ if (!this.hasAttribute('live')) return;
195
+ for (const ev of LIVE_EVENTS) {
196
+ this.listen(document, ev, () => this.#refresh());
197
+ }
198
+ }
199
+ }
200
+
201
+ registerComponent('theme-export', ThemeExport);
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Theme Export (Static Fallback)</title>
6
+ </head>
7
+ <body>
8
+ <h1>Theme Export</h1>
9
+ <noscript><p>This component works best with JavaScript enabled.</p></noscript>
10
+ <theme-export></theme-export>
11
+ </body>
12
+ </html>