@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,322 @@
1
+ /**
2
+ * DTCG (Design Tokens Community Group) deserializer for VB.
3
+ *
4
+ * Pure function: takes a parsed DTCG object → returns
5
+ * { tokens: Array<[name, value]>, ignored: string[], stats: {...} }
6
+ *
7
+ * Inverse of dtcg-serialize.js. Handles:
8
+ * - reverse prefix mapping (color/* → --color-*, etc.)
9
+ * - color rebuild from structured components (oklch / srgb)
10
+ * - dimension/duration/cubicBezier/shadow rebuild as CSS strings
11
+ * - light-dark variant folding back to light-dark()
12
+ * - $extensions["com.vanilla-breeze"] preference for round-trip-lossless
13
+ * reconstruction (expression, lightDark literal, em/% units)
14
+ * - composite typography unpacking into VB scalars
15
+ * - {curly.brace} alias resolution (intra-document) → var(--…) output
16
+ * - group-level $type inheritance per spec
17
+ *
18
+ * Spec: https://www.designtokens.org/ (stable v1, 2025-10-28)
19
+ */
20
+
21
+ const VB_NS = 'com.vanilla-breeze';
22
+
23
+ /**
24
+ * @param {any} doc
25
+ * @returns {{ tokens: Array<[string, string]>, ignored: string[], stats: { applied: number, ignored: number } }}
26
+ */
27
+ export function deserializeDTCG(doc) {
28
+ const tokens = [];
29
+ const ignored = [];
30
+ const tree = doc && typeof doc === 'object' ? doc : {};
31
+
32
+ // Walk the tree depth-first, propagating $type inheritance from groups.
33
+ walk(tree, [], undefined, tree, tokens, ignored);
34
+
35
+ return {
36
+ tokens,
37
+ ignored,
38
+ stats: { applied: tokens.length, ignored: ignored.length },
39
+ };
40
+ }
41
+
42
+ /* ---------- tree walk ---------- */
43
+
44
+ function walk(node, path, inheritedType, root, tokens, ignored) {
45
+ if (!node || typeof node !== 'object') return;
46
+
47
+ // A token is an object that has $value OR is a variant container ($root/light/dark).
48
+ if ('$value' in node) {
49
+ handleToken(node, path, inheritedType ?? node.$type, root, tokens, ignored);
50
+ return;
51
+ }
52
+
53
+ // Variant container: presence of $root or both light+dark children that have $value.
54
+ if (isVariantContainer(node)) {
55
+ handleVariantToken(node, path, inheritedType ?? node.$type ?? 'color', root, tokens, ignored);
56
+ return;
57
+ }
58
+
59
+ // Otherwise it's a group — descend, inheriting $type if the group declares one.
60
+ const groupType = node.$type ?? inheritedType;
61
+ for (const [key, child] of Object.entries(node)) {
62
+ if (key.startsWith('$')) continue;
63
+ walk(child, [...path, key], groupType, root, tokens, ignored);
64
+ }
65
+ }
66
+
67
+ function isVariantContainer(node) {
68
+ // Variants are children with $value; the container itself has no $value.
69
+ const variants = ['$root', 'light', 'dark'];
70
+ return variants.some(k => node[k] && typeof node[k] === 'object' && '$value' in node[k]);
71
+ }
72
+
73
+ /* ---------- prefix reversal ---------- */
74
+
75
+ /**
76
+ * Map a DTCG path back to the VB CSS custom-property name.
77
+ * Returns null if the path doesn't map to a known VB prefix.
78
+ *
79
+ * @param {string[]} path
80
+ * @returns {string | null}
81
+ */
82
+ function pathToVar(path) {
83
+ const [head, second, ...rest] = path;
84
+ switch (head) {
85
+ case 'color': {
86
+ if (second === 'seeds') {
87
+ // color/seeds/{name} where name is the full token (hue-primary, lightness-primary, …)
88
+ return rest.length ? `--${rest.join('-')}` : null;
89
+ }
90
+ const tail = [second, ...rest].filter(Boolean).join('-');
91
+ return tail ? `--color-${tail}` : null;
92
+ }
93
+ case 'typography': {
94
+ const leaf = rest.join('-') || second;
95
+ const child = rest.length ? rest.join('-') : '';
96
+ switch (second) {
97
+ case 'family': return child ? `--font-${child}` : null;
98
+ case 'size': return child ? `--font-size-${child}` : null;
99
+ case 'weight': return child ? `--font-weight-${child}` : null;
100
+ case 'lineHeight': return child ? `--line-height-${child}` : null;
101
+ case 'letterSpacing': return child ? `--letter-spacing-${child}` : null;
102
+ default: return null;
103
+ }
104
+ // (unreachable)
105
+ const _ = leaf;
106
+ }
107
+ case 'spacing':
108
+ return [second, ...rest].filter(Boolean).length ? `--size-${[second, ...rest].filter(Boolean).join('-')}` : null;
109
+ case 'border': {
110
+ const child = rest.join('-') || '';
111
+ if (second === 'radius') return child ? `--radius-${child}` : null;
112
+ if (second === 'width') return child ? `--border-width-${child}` : null;
113
+ return null;
114
+ }
115
+ case 'motion': {
116
+ const child = rest.join('-') || '';
117
+ if (second === 'duration') return child ? `--duration-${child}` : null;
118
+ if (second === 'easing') return child ? `--ease-${child}` : null;
119
+ return null;
120
+ }
121
+ case 'effect': {
122
+ const child = rest.join('-') || '';
123
+ if (second === 'shadow') return child ? `--shadow-${child}` : null;
124
+ return null;
125
+ }
126
+ default:
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /* ---------- token handling ---------- */
132
+
133
+ function handleToken(token, path, type, root, tokens, ignored) {
134
+ // Composite typography spans multiple VB scalars; check before pathToVar
135
+ // because the leaf name (e.g. "heading") doesn't have a single VB token.
136
+ if ((type === 'typography' || token.$type === 'typography')
137
+ && token.$value && typeof token.$value === 'object' && !Array.isArray(token.$value)
138
+ && path[0] === 'typography') {
139
+ unpackTypography(token, path, tokens);
140
+ return;
141
+ }
142
+
143
+ const cssVar = pathToVar(path);
144
+ if (!cssVar) {
145
+ ignored.push(path.join('.'));
146
+ return;
147
+ }
148
+
149
+ // Type inference from path when neither token nor group declared one.
150
+ // The DTCG spec forbids inferring from $value contents; path-based hints
151
+ // are part of group hierarchy and fine.
152
+ const effectiveType = type ?? inferTypeFromPath(path);
153
+
154
+ const ext = (token.$extensions && token.$extensions[VB_NS]) || {};
155
+
156
+ // Round-trip-lossless: prefer preserved CSS expression / literal.
157
+ if (ext.expression) { tokens.push([cssVar, ext.expression]); return; }
158
+ if (ext.lightDark) { tokens.push([cssVar, ext.lightDark]); return; }
159
+
160
+ const value = renderValue(token.$value, effectiveType, ext, root);
161
+ if (value == null) {
162
+ ignored.push(path.join('.'));
163
+ return;
164
+ }
165
+ tokens.push([cssVar, value]);
166
+ }
167
+
168
+ /**
169
+ * Infer a token type from its path (group-based, NOT $value-content-based).
170
+ * Mirrors the prefix table in pathToVar().
171
+ */
172
+ function inferTypeFromPath(path) {
173
+ const [head, second] = path;
174
+ if (head === 'color') return 'color';
175
+ if (head === 'spacing') return 'dimension';
176
+ if (head === 'border' && (second === 'radius' || second === 'width')) return 'dimension';
177
+ if (head === 'motion' && second === 'duration') return 'duration';
178
+ if (head === 'motion' && second === 'easing') return 'cubicBezier';
179
+ if (head === 'effect' && second === 'shadow') return 'shadow';
180
+ if (head === 'typography') {
181
+ if (second === 'family') return 'fontFamily';
182
+ if (second === 'size') return 'dimension';
183
+ if (second === 'weight') return 'fontWeight';
184
+ if (second === 'lineHeight') return 'number';
185
+ if (second === 'letterSpacing') return 'dimension';
186
+ }
187
+ return undefined;
188
+ }
189
+
190
+ function handleVariantToken(node, path, type, root, tokens, ignored) {
191
+ const cssVar = pathToVar(path);
192
+ if (!cssVar) { ignored.push(path.join('.')); return; }
193
+ const ext = (node.$extensions && node.$extensions[VB_NS]) || {};
194
+ if (ext.lightDark) { tokens.push([cssVar, ext.lightDark]); return; }
195
+
196
+ const light = node.light?.$value ? renderValue(node.light.$value, type, {}, root) : null;
197
+ const dark = node.dark?.$value ? renderValue(node.dark.$value, type, {}, root) : null;
198
+ const root_ = node.$root?.$value ? renderValue(node.$root.$value, type, {}, root) : null;
199
+
200
+ if (light && dark) {
201
+ tokens.push([cssVar, `light-dark(${light}, ${dark})`]);
202
+ return;
203
+ }
204
+ if (root_) { tokens.push([cssVar, root_]); return; }
205
+ if (light) { tokens.push([cssVar, light]); return; }
206
+ if (dark) { tokens.push([cssVar, dark]); return; }
207
+ ignored.push(path.join('.'));
208
+ }
209
+
210
+ /* ---------- value rendering ---------- */
211
+
212
+ function renderValue(value, type, ext, root) {
213
+ // Alias: {group.token} → resolve and rebuild as var(--…) for cross-tool clarity.
214
+ if (typeof value === 'string' && /^\{[^}]+\}$/.test(value)) {
215
+ const targetPath = value.slice(1, -1).split('.');
216
+ const targetVar = pathToVar(targetPath);
217
+ if (targetVar) return `var(${targetVar})`;
218
+ // If unmappable target, fall back to literal.
219
+ return value;
220
+ }
221
+ switch (type) {
222
+ case 'color': return renderColor(value, ext);
223
+ case 'fontFamily': return renderFontFamily(value);
224
+ case 'fontWeight': return String(value);
225
+ case 'number': return renderNumber(value, ext);
226
+ case 'dimension': return renderDimension(value, ext);
227
+ case 'duration': return renderDuration(value);
228
+ case 'cubicBezier': return Array.isArray(value) ? `cubic-bezier(${value.join(', ')})` : null;
229
+ case 'shadow': return renderShadow(value);
230
+ default:
231
+ // Best effort: stringify primitives.
232
+ if (typeof value === 'string' || typeof value === 'number') return String(value);
233
+ return null;
234
+ }
235
+ }
236
+
237
+ function renderColor(value, ext) {
238
+ if (!value || typeof value !== 'object') return null;
239
+ if (value.hex) return value.hex;
240
+ if (value.colorSpace === 'oklch') {
241
+ const [L, C, H] = value.components || [0, 0, 0];
242
+ const alpha = value.alpha != null ? ` / ${value.alpha}` : '';
243
+ return `oklch(${L} ${C} ${H}${alpha})`;
244
+ }
245
+ if (value.colorSpace === 'srgb') {
246
+ const [r, g, b] = value.components || [0, 0, 0];
247
+ const a = value.alpha;
248
+ if (a != null) {
249
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
250
+ }
251
+ return rgbToHex(r, g, b);
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function rgbToHex(r, g, b) {
257
+ const h = (n) => Math.round(n * 255).toString(16).padStart(2, '0');
258
+ return `#${h(r)}${h(g)}${h(b)}`;
259
+ }
260
+
261
+ // CSS generic + system font keywords that should NOT be quoted.
262
+ const BARE_FONT_KEYWORDS = new Set([
263
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
264
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
265
+ 'math', 'emoji', 'fangsong',
266
+ '-apple-system', 'BlinkMacSystemFont',
267
+ ]);
268
+
269
+ function renderFontFamily(value) {
270
+ if (typeof value === 'string') return value;
271
+ if (!Array.isArray(value)) return null;
272
+ return value.map((f) => {
273
+ if (BARE_FONT_KEYWORDS.has(f)) return f;
274
+ if (f.startsWith('"') || f.startsWith("'")) return f;
275
+ return `"${f}"`;
276
+ }).join(', ');
277
+ }
278
+
279
+ function renderNumber(value, ext) {
280
+ if (ext.unit === '%') return `${value}%`;
281
+ return String(value);
282
+ }
283
+
284
+ function renderDimension(value, ext) {
285
+ if (!value || typeof value !== 'object') return null;
286
+ // Honor VB extension unit hint (em, %) over the DTCG-stated unit.
287
+ const unit = ext.unit || value.unit || 'px';
288
+ return `${value.value}${unit}`;
289
+ }
290
+
291
+ function renderDuration(value) {
292
+ if (!value || typeof value !== 'object') return null;
293
+ return `${value.value}${value.unit || 'ms'}`;
294
+ }
295
+
296
+ function renderShadow(value) {
297
+ if (Array.isArray(value)) return value.map(renderShadowStop).filter(Boolean).join(', ');
298
+ return renderShadowStop(value);
299
+ }
300
+
301
+ function renderShadowStop(stop) {
302
+ if (!stop) return null;
303
+ const ox = renderDimension(stop.offsetX, {}) ?? '0px';
304
+ const oy = renderDimension(stop.offsetY, {}) ?? '0px';
305
+ const blur = renderDimension(stop.blur, {}) ?? '0px';
306
+ const spread = stop.spread ? ' ' + renderDimension(stop.spread, {}) : '';
307
+ const color = renderColor(stop.color, {}) ?? '#000';
308
+ return `${ox} ${oy} ${blur}${spread} ${color}`;
309
+ }
310
+
311
+ /* ---------- composite typography ---------- */
312
+
313
+ function unpackTypography(token, path, tokens) {
314
+ const v = token.$value;
315
+ // Take the leaf segment as the token name (typography/heading → 'heading').
316
+ const leaf = path[path.length - 1];
317
+ if (v.fontFamily != null) tokens.push([`--font-${leaf}`, renderFontFamily(v.fontFamily)]);
318
+ if (v.fontSize != null) tokens.push([`--font-size-${leaf}`, renderDimension(v.fontSize, {})]);
319
+ if (v.fontWeight != null) tokens.push([`--font-weight-${leaf}`, String(v.fontWeight)]);
320
+ if (v.lineHeight != null) tokens.push([`--line-height-${leaf}`, String(v.lineHeight)]);
321
+ if (v.letterSpacing != null) tokens.push([`--letter-spacing-${leaf}`, renderDimension(v.letterSpacing, {})]);
322
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * theme-import: Apply a DTCG tokens.json file to a preview scope.
3
+ *
4
+ * Sibling of <theme-export>. Three input modes — paste, file, URL — feed a
5
+ * pure deserializer that reverses VB's prefix mapping and rebuilds CSS
6
+ * values. Tokens land on the host's preview scope (default #preview),
7
+ * never on :root, so import experiments don't leak into the live site.
8
+ *
9
+ * Round-trip lossless for VB-authored DTCG: $extensions["com.vanilla-breeze"]
10
+ * preserves relative-color expressions, light-dark() literals, and em/% units.
11
+ *
12
+ * @attr {string} target - CSS selector for the scope to apply tokens to (default: "#preview").
13
+ * @attr {string} placeholder - Textarea placeholder. Default 'Paste DTCG tokens.json here…'.
14
+ *
15
+ * @fires theme-import:applied - When tokens are applied.
16
+ * detail: { tokens, applied, ignored, source: 'paste'|'file'|'url' }
17
+ * @fires theme-import:error - When parsing or fetch fails.
18
+ * detail: { error, phase }
19
+ *
20
+ * @example
21
+ * <theme-import target="#preview"></theme-import>
22
+ */
23
+
24
+ import { registerComponent } from '../../lib/bundle-registry.js';
25
+ import { VBElement } from '../../lib/vb-element.js';
26
+ import { deserializeDTCG } from './dtcg-deserialize.js';
27
+
28
+ class ThemeImport extends VBElement {
29
+ static observedAttributes = ['target', 'placeholder'];
30
+
31
+ setup() {
32
+ this.#render();
33
+ this.#wire();
34
+ }
35
+
36
+ attributeChangedCallback() {
37
+ if (this.isConnected && this.hasAttribute('data-upgraded')) {
38
+ this.#render();
39
+ this.#wire();
40
+ }
41
+ }
42
+
43
+ #targetEl() {
44
+ const sel = this.getAttribute('target') || '#preview';
45
+ try {
46
+ return /** @type {HTMLElement|null} */ (document.querySelector(sel));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ #render() {
53
+ const placeholder = this.getAttribute('placeholder') || 'Paste DTCG tokens.json here…';
54
+ 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)`;
55
+ const inp = `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)`;
56
+ const ta = `inline-size:100%;block-size:10rem;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`;
57
+ const row = `display:flex;flex-wrap:wrap;gap:var(--size-2xs,0.375rem);align-items:center;margin-block-end:var(--size-s,0.5rem)`;
58
+ const muted = `font-size:var(--font-size-xs,0.75rem);color:var(--color-text-muted,#666)`;
59
+
60
+ this.innerHTML = `
61
+ <div class="ti-modes" role="tablist" aria-label="Import source" style="${row}">
62
+ <button type="button" class="ti-mode" data-mode="paste" style="${btn}" role="tab" aria-selected="true">Paste</button>
63
+ <button type="button" class="ti-mode" data-mode="file" style="${btn}" role="tab" aria-selected="false">File</button>
64
+ <button type="button" class="ti-mode" data-mode="url" style="${btn}" role="tab" aria-selected="false">URL</button>
65
+ <span style="${muted};margin-inline-start:auto" class="ti-target"></span>
66
+ </div>
67
+
68
+ <div class="ti-pane ti-pane-paste" role="tabpanel">
69
+ <textarea class="ti-paste" aria-label="DTCG JSON" placeholder="${placeholder}" style="${ta}"></textarea>
70
+ <div style="${row};margin-block-start:var(--size-2xs,0.375rem)">
71
+ <button type="button" class="ti-apply-paste" style="${btn}">Apply</button>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="ti-pane ti-pane-file" role="tabpanel" hidden>
76
+ <label style="display:flex;flex-direction:column;gap:var(--size-2xs,0.375rem);${muted}">
77
+ <span>DTCG file (.tokens.json or .json)</span>
78
+ <input class="ti-file" type="file" accept=".json,.tokens,.tokens.json,application/json,application/design-tokens+json" style="${inp}">
79
+ </label>
80
+ </div>
81
+
82
+ <div class="ti-pane ti-pane-url" role="tabpanel" hidden>
83
+ <label style="display:flex;flex-direction:column;gap:var(--size-2xs,0.375rem);${muted}">
84
+ <span>DTCG URL (CORS-permitted host)</span>
85
+ <input class="ti-url" type="url" placeholder="https://example.com/theme.tokens.json" style="${inp}">
86
+ </label>
87
+ <div style="${row};margin-block-start:var(--size-2xs,0.375rem)">
88
+ <button type="button" class="ti-apply-url" style="${btn}">Fetch &amp; apply</button>
89
+ </div>
90
+ </div>
91
+
92
+ <output class="ti-result" style="display:block;margin-block-start:var(--size-s,0.5rem);${muted}"></output>
93
+
94
+ <details class="ti-ignored" style="margin-block-start:var(--size-2xs,0.375rem)" hidden>
95
+ <summary style="${muted};cursor:pointer">Ignored tokens</summary>
96
+ <ul class="ti-ignored-list" style="margin:var(--size-2xs,0.375rem) 0 0;padding-inline-start:1.25rem;${muted}"></ul>
97
+ </details>
98
+
99
+ <div style="${row};margin-block-start:var(--size-s,0.5rem)">
100
+ <button type="button" class="ti-reset" style="${btn}">Reset preview scope</button>
101
+ </div>
102
+ `;
103
+
104
+ this.#refreshTargetLabel();
105
+ }
106
+
107
+ #refreshTargetLabel() {
108
+ const span = this.querySelector('.ti-target');
109
+ if (!span) return;
110
+ const sel = this.getAttribute('target') || '#preview';
111
+ const found = this.#targetEl();
112
+ span.textContent = found ? `Target: ${sel}` : `Target ${sel} not found`;
113
+ }
114
+
115
+ #wire() {
116
+ for (const tab of this.querySelectorAll('.ti-mode')) {
117
+ this.listen(tab, 'click', () => this.#switchMode(/** @type {HTMLElement} */ (tab).dataset.mode || 'paste'));
118
+ }
119
+ const apply = this.querySelector('.ti-apply-paste');
120
+ if (apply) this.listen(apply, 'click', () => this.#applyFromPaste());
121
+ const file = /** @type {HTMLInputElement|null} */ (this.querySelector('.ti-file'));
122
+ if (file) this.listen(file, 'change', () => this.#applyFromFile(file));
123
+ const applyUrl = this.querySelector('.ti-apply-url');
124
+ if (applyUrl) this.listen(applyUrl, 'click', () => this.#applyFromURL());
125
+ const reset = this.querySelector('.ti-reset');
126
+ if (reset) this.listen(reset, 'click', () => this.#reset());
127
+ }
128
+
129
+ #switchMode(mode) {
130
+ for (const tab of this.querySelectorAll('.ti-mode')) {
131
+ const t = /** @type {HTMLElement} */ (tab);
132
+ const active = t.dataset.mode === mode;
133
+ t.setAttribute('aria-selected', String(active));
134
+ }
135
+ for (const pane of this.querySelectorAll('.ti-pane')) {
136
+ const p = /** @type {HTMLElement} */ (pane);
137
+ p.hidden = !p.classList.contains(`ti-pane-${mode}`);
138
+ }
139
+ }
140
+
141
+ #applyFromPaste() {
142
+ const ta = /** @type {HTMLTextAreaElement|null} */ (this.querySelector('.ti-paste'));
143
+ if (!ta) return;
144
+ this.#applyJSON(ta.value, 'paste');
145
+ }
146
+
147
+ /** @param {HTMLInputElement} input */
148
+ async #applyFromFile(input) {
149
+ const file = input.files?.[0];
150
+ if (!file) return;
151
+ try {
152
+ const text = await file.text();
153
+ this.#applyJSON(text, 'file');
154
+ } catch (err) {
155
+ this.#fail(err, 'file');
156
+ }
157
+ }
158
+
159
+ async #applyFromURL() {
160
+ const input = /** @type {HTMLInputElement|null} */ (this.querySelector('.ti-url'));
161
+ const url = input?.value?.trim();
162
+ if (!url) return;
163
+ try {
164
+ const res = await fetch(url, { mode: 'cors' });
165
+ if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);
166
+ const text = await res.text();
167
+ this.#applyJSON(text, 'url');
168
+ } catch (err) {
169
+ this.#fail(err, 'url');
170
+ }
171
+ }
172
+
173
+ /**
174
+ * @param {string} text
175
+ * @param {'paste'|'file'|'url'} source
176
+ */
177
+ #applyJSON(text, source) {
178
+ let doc;
179
+ try {
180
+ doc = JSON.parse(text);
181
+ } catch (err) {
182
+ this.#fail(err, 'parse');
183
+ return;
184
+ }
185
+ const result = deserializeDTCG(doc);
186
+ const target = this.#targetEl();
187
+ if (!target) {
188
+ this.#fail(new Error(`Target ${this.getAttribute('target') || '#preview'} not found`), 'apply');
189
+ return;
190
+ }
191
+ for (const [name, value] of result.tokens) {
192
+ target.style.setProperty(name, value);
193
+ }
194
+ this.#showResult(result, source);
195
+ this.dispatchEvent(new CustomEvent('theme-import:applied', {
196
+ bubbles: true,
197
+ detail: {
198
+ tokens: result.tokens,
199
+ applied: result.stats.applied,
200
+ ignored: result.ignored,
201
+ source,
202
+ },
203
+ }));
204
+ }
205
+
206
+ #reset() {
207
+ const target = this.#targetEl();
208
+ if (!target) return;
209
+ // Strip any inline custom properties — anything starting with --.
210
+ const style = target.style;
211
+ const remove = [];
212
+ for (let i = 0; i < style.length; i++) {
213
+ const name = style.item(i);
214
+ if (name.startsWith('--')) remove.push(name);
215
+ }
216
+ for (const name of remove) style.removeProperty(name);
217
+
218
+ const out = this.querySelector('.ti-result');
219
+ if (out) out.textContent = `Cleared ${remove.length} custom properties from ${this.getAttribute('target') || '#preview'}.`;
220
+ const ig = this.querySelector('.ti-ignored');
221
+ if (ig) /** @type {HTMLDetailsElement} */ (ig).hidden = true;
222
+ }
223
+
224
+ #showResult(result, source) {
225
+ const out = this.querySelector('.ti-result');
226
+ if (out) {
227
+ out.textContent = `Applied ${result.stats.applied} token${result.stats.applied === 1 ? '' : 's'} from ${source}${result.stats.ignored ? ` · ${result.stats.ignored} ignored` : ''}.`;
228
+ }
229
+ const ig = this.querySelector('.ti-ignored');
230
+ const list = this.querySelector('.ti-ignored-list');
231
+ if (ig && list) {
232
+ const det = /** @type {HTMLDetailsElement} */ (ig);
233
+ if (result.ignored.length === 0) {
234
+ det.hidden = true;
235
+ list.innerHTML = '';
236
+ } else {
237
+ det.hidden = false;
238
+ list.innerHTML = result.ignored
239
+ .map((p) => `<li>${escapeHTML(p)}</li>`)
240
+ .join('');
241
+ }
242
+ }
243
+ }
244
+
245
+ #fail(err, phase) {
246
+ const out = this.querySelector('.ti-result');
247
+ if (out) out.textContent = `Error (${phase}): ${err && err.message ? err.message : String(err)}`;
248
+ this.dispatchEvent(new CustomEvent('theme-import:error', {
249
+ bubbles: true,
250
+ detail: { error: err, phase },
251
+ }));
252
+ }
253
+ }
254
+
255
+ function escapeHTML(s) {
256
+ return String(s).replace(/[&<>"']/g, (ch) => /** @type {Record<string,string>} */ ({
257
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
258
+ })[ch]);
259
+ }
260
+
261
+ registerComponent('theme-import', ThemeImport);
262
+
263
+ export { ThemeImport };
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Theme Import (Static Fallback)</title>
6
+ </head>
7
+ <body>
8
+ <h1>Theme Import</h1>
9
+ <noscript><p>This component works best with JavaScript enabled.</p></noscript>
10
+ <theme-import target="#preview"></theme-import>
11
+ <section id="preview">
12
+ <p>Preview scope.</p>
13
+ </section>
14
+ </body>
15
+ </html>
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "token-specimen",
4
+ "type": "web-component",
5
+ "description": "Unified design token scale display. Renders shadows, radii, borders, colors, or sizes based on the type attribute.",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": ["@flow"]
9
+ },
10
+ "attributes": [
11
+ { "name": "type", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["shadow", "radius", "border", "color", "size", "icon"], "description": "Token type to display" },
12
+ { "name": "tokens", "kind": "host-api", "purpose": "config", "type": "string", "description": "Comma-separated token names (for icon type: comma-separated icon names). Defaults vary by type" },
13
+ { "name": "prefix", "kind": "host-api", "purpose": "config", "type": "string", "description": "CSS variable prefix (auto-set from type if omitted; ignored for icon type)" },
14
+ { "name": "show-values", "kind": "host-api", "purpose": "config", "type": "boolean", "default": "true", "description": "Show computed token values (for icon type: show icon name labels)" },
15
+ { "name": "label", "kind": "host-api", "purpose": "config", "type": "string", "description": "Optional heading label" },
16
+ { "name": "size", "kind": "host-api", "purpose": "config", "type": "string", "description": "Icon size (icon type only): xs, sm, md, lg, xl, 2xl" },
17
+ { "name": "icon-set", "kind": "host-api", "purpose": "config", "type": "string", "description": "Icon set (icon type only): lucide, phosphor, tabler, etc. Defaults to icon-wc's default" },
18
+ { "name": "editable", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Turn value cells into inputs that write the token on the target scope. Ignored for icon type." },
19
+ { "name": "target", "kind": "host-api", "purpose": "config", "type": "string", "description": "CSS selector for the element to receive token overrides (default: \":root\"). Only used when editable." }
20
+ ],
21
+ "events": [
22
+ { "name": "token-specimen:change" }
23
+ ]
24
+ }