@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,256 @@
1
+ /**
2
+ * token-specimen: Unified design token scale display
3
+ *
4
+ * One component for multiple token types — shadows, radii, borders,
5
+ * colors, and sizes. The `type` attribute controls rendering while
6
+ * sharing the same core API (tokens, prefix, show-values, label).
7
+ *
8
+ * Reads CSS custom properties and renders a visual specimen with
9
+ * computed values after paint. All layout is inline so the component
10
+ * works without external CSS.
11
+ *
12
+ * @attr {string} type - Token type: "shadow", "radius", "border", "color", "size", "icon"
13
+ * @attr {string} tokens - Comma-separated token names (defaults vary by type)
14
+ * @attr {string} prefix - CSS variable prefix (auto-set from type if omitted)
15
+ * @attr {boolean} show-values - Show computed values (default: true)
16
+ * @attr {string} label - Optional heading label
17
+ * @attr {string} size - Icon size (icon type only): xs, sm, md, lg, xl, 2xl
18
+ * @attr {string} icon-set - Icon set (icon type only, default: "lucide")
19
+ * @attr {boolean} editable - Turn value cells into inputs that write the token on target scope
20
+ * @attr {string} target - CSS selector for the element to receive token overrides (default: ":root")
21
+ *
22
+ * @fires token-specimen:change - When a token is edited. detail: { name, value, token, target }
23
+ *
24
+ * @example
25
+ * <token-specimen type="shadow"></token-specimen>
26
+ * <token-specimen type="radius" tokens="s,m,l,xl,full"></token-specimen>
27
+ * <token-specimen type="icon" tokens="chevron-right,check,x,search"></token-specimen>
28
+ * <token-specimen type="color" editable></token-specimen>
29
+ */
30
+ import { registerComponent } from '../../lib/bundle-registry.js';
31
+ import { VBElement } from '../../lib/vb-element.js';
32
+
33
+ const TYPE_DEFAULTS = {
34
+ shadow: {
35
+ prefix: '--shadow-',
36
+ tokens: 'xs,s,m,l,xl,2xl',
37
+ },
38
+ radius: {
39
+ prefix: '--radius-',
40
+ tokens: 'xs,s,m,l,xl,2xl,full',
41
+ },
42
+ border: {
43
+ prefix: '--border-width-',
44
+ tokens: 'thin,medium,thick',
45
+ },
46
+ color: {
47
+ prefix: '--color-',
48
+ tokens: 'primary,secondary,accent,success,warning,error,info',
49
+ },
50
+ size: {
51
+ prefix: '--size-',
52
+ tokens: '3xs,2xs,xs,s,m,l,xl,2xl,3xl',
53
+ },
54
+ icon: {
55
+ prefix: '',
56
+ tokens: 'home,search,settings,user,bell,heart,star,check,x,chevron-right,menu,trash',
57
+ },
58
+ };
59
+
60
+ const RENDERERS = {
61
+ shadow: renderShadow,
62
+ radius: renderRadius,
63
+ border: renderBorder,
64
+ color: renderColor,
65
+ size: renderSize,
66
+ icon: renderIcon,
67
+ };
68
+
69
+ class TokenSpecimen extends VBElement {
70
+ static observedAttributes = ['type', 'tokens', 'prefix', 'show-values', 'label', 'size', 'icon-set', 'editable', 'target'];
71
+
72
+ setup() { this.#render(); }
73
+
74
+ attributeChangedCallback() {
75
+ if (this.isConnected) this.#render();
76
+ }
77
+
78
+ #render() {
79
+ const type = this.getAttribute('type') || 'shadow';
80
+ const defaults = TYPE_DEFAULTS[type] || TYPE_DEFAULTS.shadow;
81
+ const prefix = this.getAttribute('prefix') || defaults.prefix;
82
+ const tokensAttr = this.getAttribute('tokens') || defaults.tokens;
83
+ const showValues = this.getAttribute('show-values') !== 'false';
84
+ const label = this.getAttribute('label') || '';
85
+ const tokens = tokensAttr.split(',').map(t => t.trim());
86
+
87
+ const renderer = RENDERERS[type] || RENDERERS.shadow;
88
+ let html = '';
89
+
90
+ if (label) {
91
+ html += `<p style="font-size:0.75rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--color-text-muted,#666);margin-block-end:0.75rem;font-family:var(--font-sans,system-ui)">${label}</p>`;
92
+ }
93
+
94
+ html += renderer(tokens, prefix, showValues, this);
95
+ this.innerHTML = html;
96
+
97
+ const editable = this.hasAttribute('editable') && type !== 'icon';
98
+
99
+ if (showValues && type !== 'icon') {
100
+ requestAnimationFrame(() => {
101
+ this.#readComputedValues(type, prefix, tokens);
102
+ if (editable) this.#injectEditors(type, prefix);
103
+ });
104
+ } else if (editable) {
105
+ requestAnimationFrame(() => this.#injectEditors(type, prefix));
106
+ }
107
+ }
108
+
109
+ #injectEditors(type, prefix) {
110
+ const cs = getComputedStyle(this);
111
+ this.querySelectorAll('[data-token-value]').forEach((node) => {
112
+ const el = /** @type {HTMLElement} */ (node);
113
+ const name = el.dataset.tokenValue;
114
+ const raw = cs.getPropertyValue(`${prefix}${name}`).trim();
115
+ const input = document.createElement('input');
116
+ input.type = type === 'color' && /^#[0-9a-f]{3,8}$/i.test(raw) ? 'color' : 'text';
117
+ input.value = raw || '';
118
+ input.className = 'ts-edit';
119
+ input.setAttribute('data-token', name || '');
120
+ input.setAttribute('aria-label', `${name} value`);
121
+ input.style.cssText = `font-family:var(--font-mono,monospace);font-size:0.625rem;padding:0.125rem 0.25rem;border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);background:var(--color-surface,#fff);color:var(--color-text,#222);inline-size:${type === 'color' ? '3rem' : '100%'};${type === 'color' ? 'block-size:1.5rem;padding:0;' : 'max-inline-size:8rem;'}`;
122
+ el.replaceWith(input);
123
+ this.listen(input, 'change', () => this.#applyEdit(input, prefix));
124
+ this.listen(input, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Enter') this.#applyEdit(input, prefix); });
125
+ });
126
+ }
127
+
128
+ #applyEdit(input, prefix) {
129
+ const name = input.getAttribute('data-token');
130
+ const value = input.value.trim();
131
+ if (!name || !value) return;
132
+ const target = /** @type {HTMLElement | null} */ (this.#resolveTarget());
133
+ const token = `${prefix}${name}`;
134
+ if (target) target.style.setProperty(token, value);
135
+ this.dispatchEvent(new CustomEvent('token-specimen:change', {
136
+ bubbles: true,
137
+ detail: { name, value, token, target: this.getAttribute('target') || ':root' },
138
+ }));
139
+ }
140
+
141
+ #resolveTarget() {
142
+ const sel = this.getAttribute('target') || ':root';
143
+ try {
144
+ return sel === ':root' ? document.documentElement : document.querySelector(sel);
145
+ } catch {
146
+ return document.documentElement;
147
+ }
148
+ }
149
+
150
+ #readComputedValues(type, prefix, tokens) {
151
+ const cs = getComputedStyle(this);
152
+ this.querySelectorAll('[data-token-value]').forEach(node => {
153
+ const el = /** @type {HTMLElement} */ (node);
154
+ const name = el.dataset.tokenValue;
155
+ const raw = cs.getPropertyValue(`${prefix}${name}`).trim();
156
+ if (type === 'radius' || type === 'size') {
157
+ // Show px for length values
158
+ const sample = this.querySelector(`[data-token-sample="${name}"]`);
159
+ if (sample) {
160
+ const prop = type === 'radius' ? 'borderRadius' : 'width';
161
+ const rect = type === 'size' ? sample.getBoundingClientRect().width : null;
162
+ el.textContent = rect != null
163
+ ? `${Math.round(rect * 100) / 100}px`
164
+ : raw || '—';
165
+ }
166
+ } else {
167
+ el.textContent = raw || '—';
168
+ }
169
+ });
170
+ }
171
+ }
172
+
173
+ // ── Type renderers ────────────────────────────────────────────
174
+
175
+ function renderShadow(tokens, prefix, showValues) {
176
+ let html = `<div role="list" style="display:flex;flex-wrap:wrap;gap:1rem;align-items:end">`;
177
+ for (const name of tokens) {
178
+ html += `<div role="listitem" style="text-align:center">
179
+ <div style="width:7rem;height:5rem;background:var(--color-surface,#fff);border-radius:var(--radius-m,0.5rem);box-shadow:var(${prefix}${name})" aria-hidden="true"></div>
180
+ <p style="font-family:var(--font-mono,monospace);font-size:0.75rem;color:var(--color-text-muted,#666);margin-block-start:0.5rem">${name}</p>
181
+ ${showValues ? `<p data-token-value="${name}" style="font-family:var(--font-mono,monospace);font-size:0.625rem;color:var(--color-text-muted,#999);max-width:7rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></p>` : ''}
182
+ </div>`;
183
+ }
184
+ html += '</div>';
185
+ return html;
186
+ }
187
+
188
+ function renderRadius(tokens, prefix, showValues) {
189
+ let html = `<div role="list" style="display:flex;flex-wrap:wrap;gap:1rem;align-items:end">`;
190
+ for (const name of tokens) {
191
+ html += `<div role="listitem" style="text-align:center">
192
+ <div data-token-sample="${name}" style="width:4.5rem;height:4.5rem;background:var(--color-primary,oklch(55% 0.2 260));border-radius:var(${prefix}${name});display:flex;align-items:center;justify-content:center;color:#fff;font-size:0.75rem;font-family:var(--font-mono,monospace)" aria-hidden="true">${name}</div>
193
+ ${showValues ? `<p data-token-value="${name}" style="font-family:var(--font-mono,monospace);font-size:0.625rem;color:var(--color-text-muted,#999);margin-block-start:0.25rem"></p>` : ''}
194
+ </div>`;
195
+ }
196
+ html += '</div>';
197
+ return html;
198
+ }
199
+
200
+ function renderBorder(tokens, prefix, showValues) {
201
+ let html = `<div role="list" style="display:flex;flex-direction:column;gap:0.75rem">`;
202
+ for (const name of tokens) {
203
+ html += `<div role="listitem" style="display:grid;grid-template-columns:4rem 1fr auto;align-items:center;gap:0.75rem">
204
+ <span style="font-family:var(--font-mono,monospace);font-size:0.875rem;color:var(--color-text-muted,#666);text-align:end">${name}</span>
205
+ <div style="border-block-start:var(${prefix}${name}) solid var(--color-text,#333);min-inline-size:4rem" aria-hidden="true"></div>
206
+ ${showValues ? `<span data-token-value="${name}" style="font-family:var(--font-mono,monospace);font-size:0.75rem;color:var(--color-text-muted,#666);font-variant-numeric:tabular-nums"></span>` : ''}
207
+ </div>`;
208
+ }
209
+ html += '</div>';
210
+ return html;
211
+ }
212
+
213
+ function renderColor(tokens, prefix, showValues) {
214
+ let html = `<div role="list" style="display:flex;flex-wrap:wrap;gap:0.75rem">`;
215
+ for (const name of tokens) {
216
+ html += `<div role="listitem" style="text-align:center">
217
+ <div style="width:4rem;height:3rem;background:var(${prefix}${name});border-radius:var(--radius-s,0.25rem);border:1px solid var(--color-border,#ddd)" aria-hidden="true"></div>
218
+ <p style="font-family:var(--font-mono,monospace);font-size:0.625rem;color:var(--color-text-muted,#666);margin-block-start:0.25rem">${name}</p>
219
+ ${showValues ? `<p data-token-value="${name}" style="font-family:var(--font-mono,monospace);font-size:0.5625rem;color:var(--color-text-muted,#999);max-width:4rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></p>` : ''}
220
+ </div>`;
221
+ }
222
+ html += '</div>';
223
+ return html;
224
+ }
225
+
226
+ function renderSize(tokens, prefix, showValues) {
227
+ let html = `<div role="list" style="display:flex;flex-direction:column;gap:0.25rem">`;
228
+ for (const name of tokens) {
229
+ html += `<div role="listitem" style="display:grid;grid-template-columns:3rem 1fr auto;align-items:center;gap:0.75rem;min-block-size:1.75rem">
230
+ <span style="font-family:var(--font-mono,monospace);font-size:0.875rem;color:var(--color-text-muted,#666);text-align:end">${name}</span>
231
+ <div data-token-sample="${name}" style="display:block;block-size:var(--size-m,1rem);min-inline-size:2px;inline-size:var(${prefix}${name});background:var(--color-interactive,oklch(55% 0.2 260));border-radius:var(--radius-s,0.25rem)" aria-hidden="true"></div>
232
+ ${showValues ? `<span data-token-value="${name}" style="font-family:var(--font-mono,monospace);font-size:0.75rem;color:var(--color-text-muted,#666);font-variant-numeric:tabular-nums;min-inline-size:3.5rem;text-align:end"></span>` : ''}
233
+ </div>`;
234
+ }
235
+ html += '</div>';
236
+ return html;
237
+ }
238
+
239
+ function renderIcon(tokens, _prefix, showValues, host) {
240
+ const size = host?.getAttribute('size') || 'md';
241
+ const iconSet = host?.getAttribute('icon-set') || '';
242
+ const setAttr = iconSet ? ` set="${iconSet}"` : '';
243
+ let html = `<div role="list" style="display:flex;flex-wrap:wrap;gap:1rem">`;
244
+ for (const name of tokens) {
245
+ html += `<div role="listitem" style="display:flex;flex-direction:column;align-items:center;gap:0.375rem;min-inline-size:4.5rem">
246
+ <span style="display:inline-flex;align-items:center;justify-content:center;padding:var(--size-s,0.75rem);background:var(--color-surface-raised,#f5f5f5);border-radius:var(--radius-s,0.25rem);border:1px solid var(--color-border,#ddd);color:var(--color-text,#222)">
247
+ <icon-wc name="${name}" size="${size}"${setAttr}></icon-wc>
248
+ </span>
249
+ ${showValues ? `<code style="font-family:var(--font-mono,monospace);font-size:0.625rem;color:var(--color-text-muted,#666);max-inline-size:5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${name}</code>` : ''}
250
+ </div>`;
251
+ }
252
+ html += '</div>';
253
+ return html;
254
+ }
255
+
256
+ registerComponent('token-specimen', TokenSpecimen);
@@ -0,0 +1,47 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Token Specimen (Static Fallback)</title>
7
+ <style>
8
+ /*
9
+ Minimal baseline styling for the no-JS fallback.
10
+ A production page would load the project stylesheet instead;
11
+ these rules exist only so the file is self-contained.
12
+ */
13
+ *, *::before, *::after { box-sizing: border-box; }
14
+
15
+ body {
16
+ font-family: system-ui, sans-serif;
17
+ line-height: 1.5;
18
+ max-inline-size: 40rem;
19
+ margin-inline: auto;
20
+ padding: 2rem 1rem;
21
+ }
22
+ </style>
23
+ </head>
24
+ <body>
25
+
26
+ <!--
27
+ Upgrade relationship
28
+ ====================
29
+ This static HTML IS the baseline that <token-specimen> enhances.
30
+
31
+ When JavaScript loads and the <token-specimen> custom element is
32
+ defined, the markup is progressively enhanced with additional
33
+ interactivity, ARIA attributes, and keyboard navigation.
34
+
35
+ Without JS the core content remains accessible and functional.
36
+ -->
37
+
38
+ <h1>Token Specimen</h1>
39
+
40
+ <noscript>
41
+ <p>This component works best with JavaScript enabled.</p>
42
+ </noscript>
43
+
44
+ <!-- TODO: Add meaningful static fallback markup for <token-specimen> -->
45
+
46
+ </body>
47
+ </html>
@@ -0,0 +1,10 @@
1
+ /* token-specimen: unified token scale display */
2
+
3
+ token-specimen:not(:defined) {
4
+ display: block;
5
+ min-height: 2rem;
6
+ }
7
+
8
+ token-specimen {
9
+ display: block;
10
+ }
@@ -0,0 +1,91 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "type-specimen",
4
+ "type": "web-component",
5
+ "description": "Typography specimen display",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": [
9
+ "@flow"
10
+ ]
11
+ },
12
+ "attributes": [
13
+ {
14
+ "name": "font-family",
15
+ "kind": "host-api",
16
+ "purpose": "config",
17
+ "type": "string",
18
+ "description": "CSS font-family value"
19
+ },
20
+ {
21
+ "name": "label",
22
+ "kind": "host-api",
23
+ "purpose": "config",
24
+ "type": "string",
25
+ "description": "Display name for the font (optional, falls back to font-family)"
26
+ },
27
+ {
28
+ "name": "sample",
29
+ "kind": "host-api",
30
+ "purpose": "config",
31
+ "type": "string",
32
+ "description": "Custom sample text (default: \"The quick brown fox...\")"
33
+ },
34
+ {
35
+ "name": "show-scale",
36
+ "kind": "host-api",
37
+ "purpose": "config",
38
+ "type": "boolean",
39
+ "description": "Show VB type scale (xs–5xl)"
40
+ },
41
+ {
42
+ "name": "show-weights",
43
+ "kind": "host-api",
44
+ "purpose": "config",
45
+ "type": "boolean",
46
+ "description": "Show weight scale (100–900)"
47
+ },
48
+ {
49
+ "name": "show-characters",
50
+ "kind": "host-api",
51
+ "purpose": "config",
52
+ "type": "boolean",
53
+ "description": "Show character grid"
54
+ },
55
+ {
56
+ "name": "weights",
57
+ "kind": "host-api",
58
+ "purpose": "config",
59
+ "type": "string",
60
+ "description": "Comma-separated available weights (default: \"400,700\")"
61
+ },
62
+ {
63
+ "name": "editable",
64
+ "kind": "host-api",
65
+ "purpose": "config",
66
+ "type": "boolean",
67
+ "description": "Turn the font-family label into a live input + quick-picks that write a CSS custom property."
68
+ },
69
+ {
70
+ "name": "target",
71
+ "kind": "host-api",
72
+ "purpose": "config",
73
+ "type": "string",
74
+ "description": "CSS selector for the element to receive token overrides (default: \":root\"). Only used when editable."
75
+ },
76
+ {
77
+ "name": "token",
78
+ "kind": "host-api",
79
+ "purpose": "config",
80
+ "type": "string",
81
+ "description": "Token name to write when editing (default: \"font-family-base\"). Only used when editable."
82
+ }
83
+ ],
84
+ "events": [
85
+ {
86
+ "name": "type-specimen:change"
87
+ }
88
+ ],
89
+ "childAttributes": [],
90
+ "structure": []
91
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * type-specimen: Typography specimen display
3
+ *
4
+ * Renders a font specimen with sample text, character grid, weight scale,
5
+ * and optional type scale display using VB's 9-step system.
6
+ *
7
+ * @attr {string} font-family - CSS font-family value
8
+ * @attr {string} label - Display name for the font (optional, falls back to font-family)
9
+ * @attr {string} sample - Custom sample text (default: "The quick brown fox...")
10
+ * @attr {boolean} show-scale - Show VB type scale (xs–5xl)
11
+ * @attr {boolean} show-weights - Show weight scale (100–900)
12
+ * @attr {boolean} show-characters - Show character grid
13
+ * @attr {string} weights - Comma-separated available weights (default: "400,700")
14
+ * @attr {boolean} editable - Turn the font-family label into a live input that writes a CSS custom property
15
+ * @attr {string} target - CSS selector for the element to receive token overrides (default: ":root")
16
+ * @attr {string} token - Token name to write when editing (default: "font-family-base")
17
+ *
18
+ * @fires type-specimen:change - When edited. detail: { fontFamily, token, target }
19
+ *
20
+ * @example
21
+ * <type-specimen font-family="Georgia" label="Georgia" show-characters show-weights></type-specimen>
22
+ * <type-specimen font-family="'Inter', sans-serif" editable show-scale></type-specimen>
23
+ */
24
+ import { registerComponent } from '../../lib/bundle-registry.js';
25
+ import { VBElement } from '../../lib/vb-element.js';
26
+
27
+ const QUICK_FONTS = ['system-ui', 'Georgia', 'Inter, sans-serif', '"JetBrains Mono", monospace', 'Verdana', 'Cambria', 'ui-serif'];
28
+
29
+ class TypeSpecimen extends VBElement {
30
+ static observedAttributes = ['font-family', 'label', 'sample', 'show-scale', 'show-weights', 'show-characters', 'weights', 'editable', 'target', 'token'];
31
+
32
+ setup() {
33
+ this.#render();
34
+ this.#wireEditing();
35
+ }
36
+
37
+ attributeChangedCallback() {
38
+ if (this.isConnected) {
39
+ this.#render();
40
+ this.#wireEditing();
41
+ }
42
+ }
43
+
44
+ #render() {
45
+ const fontFamily = this.getAttribute('font-family') || 'system-ui';
46
+ const label = this.getAttribute('label') || fontFamily.replace(/['"]/g, '').split(',')[0];
47
+ const sample = this.getAttribute('sample') || 'The quick brown fox jumps over the lazy dog';
48
+ const showScale = this.hasAttribute('show-scale');
49
+ const showWeights = this.hasAttribute('show-weights');
50
+ const showChars = this.hasAttribute('show-characters');
51
+ const weightsAttr = this.getAttribute('weights') || '300,400,500,600,700';
52
+ const weights = weightsAttr.split(',').map(w => w.trim());
53
+
54
+ let html = '';
55
+
56
+ const editable = this.hasAttribute('editable');
57
+
58
+ // Header with font name and large sample
59
+ const labelHTML = editable
60
+ ? `<input type="text" class="specimen-font-input" value="${fontFamily.replace(/"/g, '&quot;')}" aria-label="Font family" style="font:inherit;padding:0.25rem 0.5rem;border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);min-inline-size:18rem;max-inline-size:100%;font-family:${fontFamily};font-size:var(--font-size-sm,0.875rem)">
61
+ <span class="specimen-quick" style="display:inline-flex;flex-wrap:wrap;gap:0.25rem;margin-inline-start:0.5rem">
62
+ ${QUICK_FONTS.map((f) => `<button type="button" class="specimen-quick-btn" data-font="${f.replace(/"/g, '&quot;')}" style="font:inherit;font-size:var(--font-size-xs,0.75rem);padding:0.125rem 0.5rem;border:1px solid var(--color-border,#ccc);border-radius:999px;background:var(--color-surface,#fff);color:var(--color-text,#222);cursor:pointer">${f.split(',')[0].replace(/["']/g, '')}</button>`).join('')}
63
+ </span>`
64
+ : `<span class="specimen-label">${label}</span>`;
65
+
66
+ html += `<div class="specimen-header" style="font-family:${fontFamily}">
67
+ ${labelHTML}
68
+ <p class="specimen-sample" contenteditable="plaintext-only" spellcheck="false">${sample}</p>
69
+ </div>`;
70
+
71
+ // Character grid
72
+ if (showChars) {
73
+ html += `<div class="specimen-chars" style="font-family:${fontFamily}">
74
+ <div class="char-row"><span class="char-label">Upper</span>${'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(c => `<span>${c}</span>`).join('')}</div>
75
+ <div class="char-row"><span class="char-label">Lower</span>${'abcdefghijklmnopqrstuvwxyz'.split('').map(c => `<span>${c}</span>`).join('')}</div>
76
+ <div class="char-row"><span class="char-label">Digits</span>${'0123456789'.split('').map(c => `<span>${c}</span>`).join('')}</div>
77
+ <div class="char-row"><span class="char-label">Punct</span>${'!@#$%^&*()_+-=[]{}|;:,.<>?'.split('').map(c => `<span>${c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '&' ? '&amp;' : c}</span>`).join('')}</div>
78
+ </div>`;
79
+ }
80
+
81
+ // Weight scale
82
+ if (showWeights) {
83
+ html += `<div class="specimen-weights">`;
84
+ for (const w of weights) {
85
+ html += `<div class="weight-sample" style="font-family:${fontFamily};font-weight:${w}">
86
+ <span class="weight-label">${w}</span>
87
+ <span class="weight-text">Aa</span>
88
+ </div>`;
89
+ }
90
+ html += `</div>`;
91
+ }
92
+
93
+ // VB type scale
94
+ if (showScale) {
95
+ const scale = [
96
+ { name: 'xs', rem: 0.75 },
97
+ { name: 'sm', rem: 0.875 },
98
+ { name: 'md', rem: 1 },
99
+ { name: 'lg', rem: 1.125 },
100
+ { name: 'xl', rem: 1.25 },
101
+ { name: '2xl', rem: 1.5 },
102
+ { name: '3xl', rem: 1.875 },
103
+ { name: '4xl', rem: 2.25 },
104
+ { name: '5xl', rem: 3 },
105
+ ];
106
+ html += `<div class="specimen-scale">`;
107
+ for (const step of scale) {
108
+ html += `<div class="scale-step" style="font-family:${fontFamily};font-size:${step.rem}rem">
109
+ <span class="scale-label">${step.name}</span>
110
+ <span class="scale-text">${sample.substring(0, 30)}</span>
111
+ </div>`;
112
+ }
113
+ html += `</div>`;
114
+ }
115
+
116
+ this.innerHTML = html;
117
+ }
118
+
119
+ #wireEditing() {
120
+ if (!this.hasAttribute('editable')) return;
121
+ const input = /** @type {HTMLInputElement | null} */ (this.querySelector('.specimen-font-input'));
122
+ if (input) {
123
+ this.listen(input, 'input', () => this.#applyFontFamily(input.value));
124
+ }
125
+ this.querySelectorAll('.specimen-quick-btn').forEach((btn) => {
126
+ this.listen(btn, 'click', () => {
127
+ const font = btn.getAttribute('data-font') || '';
128
+ if (input) input.value = font;
129
+ this.#applyFontFamily(font);
130
+ });
131
+ });
132
+ }
133
+
134
+ #applyFontFamily(value) {
135
+ const target = /** @type {HTMLElement | null} */ (this.#resolveTarget());
136
+ const token = this.getAttribute('token') || 'font-family-base';
137
+ if (target) target.style.setProperty(`--${token}`, value);
138
+ // Update the attribute so the specimen re-renders with the new font
139
+ this.setAttribute('font-family', value);
140
+ this.dispatchEvent(new CustomEvent('type-specimen:change', {
141
+ bubbles: true,
142
+ detail: { fontFamily: value, token, target: this.getAttribute('target') || ':root' },
143
+ }));
144
+ }
145
+
146
+ #resolveTarget() {
147
+ const sel = this.getAttribute('target') || ':root';
148
+ try {
149
+ return sel === ':root' ? document.documentElement : document.querySelector(sel);
150
+ } catch {
151
+ return document.documentElement;
152
+ }
153
+ }
154
+ }
155
+
156
+ registerComponent('type-specimen', TypeSpecimen);
@@ -0,0 +1,84 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Type Specimen — Static Fallback</title>
7
+ </head>
8
+ <body>
9
+ <!--
10
+ Static fallback for <type-specimen>.
11
+
12
+ Upgrade relationship:
13
+ - Without JS: The font specimen is shown as static paragraphs at
14
+ different sizes, a character grid, and weight samples — all using
15
+ standard HTML and inline font-family styles. The content is readable
16
+ and demonstrates the typeface without any JavaScript.
17
+ - With JS: <type-specimen> reads `font-family`, `label`, `sample`,
18
+ `show-scale`, `show-weights`, and `show-characters` attributes to
19
+ dynamically render a specimen header with an editable sample text
20
+ (contenteditable), a character grid (upper, lower, digits, punctuation),
21
+ a weight scale (100-900), and the VB 9-step type scale (xs-5xl).
22
+
23
+ The static form preserves the core purpose — showing how a typeface
24
+ looks at different sizes and weights.
25
+ -->
26
+
27
+ <main>
28
+ <h1>Type Specimen</h1>
29
+
30
+ <section>
31
+ <h2 style="font-family: Georgia, serif">Georgia</h2>
32
+
33
+ <p style="font-family: Georgia, serif; font-size: 2.25rem">
34
+ The quick brown fox jumps over the lazy dog
35
+ </p>
36
+
37
+ <h3>Character Set</h3>
38
+ <p style="font-family: Georgia, serif; letter-spacing: 0.1em">
39
+ ABCDEFGHIJKLMNOPQRSTUVWXYZ<br>
40
+ abcdefghijklmnopqrstuvwxyz<br>
41
+ 0123456789<br>
42
+ !@#$%^&amp;*()_+-=[]{}|;:,.&lt;&gt;?
43
+ </p>
44
+
45
+ <h3>Weights</h3>
46
+ <p style="font-family: Georgia, serif; font-weight: 400">
47
+ <strong>400</strong> — The quick brown fox jumps over the lazy dog
48
+ </p>
49
+ <p style="font-family: Georgia, serif; font-weight: 700">
50
+ <strong>700</strong> — The quick brown fox jumps over the lazy dog
51
+ </p>
52
+
53
+ <h3>Type Scale</h3>
54
+ <p style="font-family: Georgia, serif; font-size: 0.75rem">
55
+ <small>xs (0.75rem)</small> — The quick brown fox jumps over
56
+ </p>
57
+ <p style="font-family: Georgia, serif; font-size: 0.875rem">
58
+ <small>sm (0.875rem)</small> — The quick brown fox jumps over
59
+ </p>
60
+ <p style="font-family: Georgia, serif; font-size: 1rem">
61
+ <small>md (1rem)</small> — The quick brown fox jumps over
62
+ </p>
63
+ <p style="font-family: Georgia, serif; font-size: 1.125rem">
64
+ <small>lg (1.125rem)</small> — The quick brown fox jumps over
65
+ </p>
66
+ <p style="font-family: Georgia, serif; font-size: 1.25rem">
67
+ <small>xl (1.25rem)</small> — The quick brown fox jumps over
68
+ </p>
69
+ <p style="font-family: Georgia, serif; font-size: 1.5rem">
70
+ <small>2xl (1.5rem)</small> — The quick brown fox jumps over
71
+ </p>
72
+ <p style="font-family: Georgia, serif; font-size: 1.875rem">
73
+ <small>3xl (1.875rem)</small> — The quick brown fox jumps over
74
+ </p>
75
+ <p style="font-family: Georgia, serif; font-size: 2.25rem">
76
+ <small>4xl (2.25rem)</small> — The quick brown fox jumps
77
+ </p>
78
+ <p style="font-family: Georgia, serif; font-size: 3rem">
79
+ <small>5xl (3rem)</small> — The quick brown fox
80
+ </p>
81
+ </section>
82
+ </main>
83
+ </body>
84
+ </html>