@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,261 @@
1
+ /**
2
+ * gradient-builder — Interactive CSS gradient tool
3
+ *
4
+ * Visual gradient preview with color stop management, angle/type controls,
5
+ * color space selection, and CSS export. Companion to palette-generator.
6
+ *
7
+ * @attr {string} colors - Comma-separated initial stop colors
8
+ * @attr {string} type - "linear" (default) or "radial"
9
+ * @attr {number} angle - Gradient angle in degrees (default: 90)
10
+ * @attr {string} interpolation - Color space: "oklab" (default), "oklch", "srgb"
11
+ * @attr {boolean} show-export - Show Copy CSS toolbar
12
+ * @attr {boolean} show-controls - Show type/angle/space controls (default: true)
13
+ *
14
+ * @fires gradient-builder:change - { css, stops, type, angle, interpolation }
15
+ *
16
+ * @example
17
+ * <gradient-builder colors="#6366f1,#ec4899" show-export></gradient-builder>
18
+ */
19
+
20
+ import { registerComponent } from '../../lib/bundle-registry.js';
21
+ import { VBElement } from '../../lib/vb-element.js';
22
+ import { copyText } from '../../utils/copy-init.js';
23
+ import { buildGradientCSS, parseColorStops, defaultStops } from './_gradient-utils.js';
24
+
25
+ class GradientBuilder extends VBElement {
26
+ static observedAttributes = ['colors', 'type', 'angle', 'interpolation', 'show-export', 'show-controls'];
27
+
28
+ /** @type {import('./_gradient-utils.js').GradientStop[]} */
29
+ #stops = [];
30
+ #type = 'linear';
31
+ #angle = 90;
32
+ #interpolation = 'oklab';
33
+
34
+ setup() {
35
+ this.#stops = parseColorStops(this.getAttribute('colors') ?? '');
36
+ this.#type = this.getAttribute('type') || 'linear';
37
+ this.#angle = Number(this.getAttribute('angle')) || 90;
38
+ this.#interpolation = this.getAttribute('interpolation') || 'oklab';
39
+ this.#render();
40
+ return true;
41
+ }
42
+
43
+ attributeChangedCallback(name, oldVal, newVal) {
44
+ if (oldVal === newVal || !this.isConnected) return;
45
+ if (name === 'colors') this.#stops = parseColorStops(newVal);
46
+ if (name === 'type') this.#type = newVal || 'linear';
47
+ if (name === 'angle') this.#angle = Number(newVal) || 90;
48
+ if (name === 'interpolation') this.#interpolation = newVal || 'oklab';
49
+ this.#render();
50
+ }
51
+
52
+ get css() {
53
+ return buildGradientCSS(this.#stops, {
54
+ type: this.#type,
55
+ angle: this.#angle,
56
+ interpolation: this.#interpolation,
57
+ });
58
+ }
59
+
60
+ #render() {
61
+ const showExport = this.hasAttribute('show-export');
62
+ const showControls = this.getAttribute('show-controls') !== 'false';
63
+ const css = this.css;
64
+
65
+ const gap = 'var(--size-m, 1rem)';
66
+ const smGap = 'var(--size-s, 0.75rem)';
67
+ const xsGap = 'var(--size-xs, 0.5rem)';
68
+ const radius = 'var(--radius-m, 0.5rem)';
69
+ const border = 'var(--color-border, #ddd)';
70
+ const surface = 'var(--color-surface, #fff)';
71
+ const muted = 'var(--color-text-muted, #666)';
72
+ const mono = 'var(--font-mono, monospace)';
73
+ const smFont = 'var(--font-size-sm, 0.875rem)';
74
+ const xsFont = 'var(--font-size-xs, 0.75rem)';
75
+
76
+ // Preview strip
77
+ const preview = `<div class="gb-preview" style="height:4rem;border-radius:${radius};background:${css};border:1px solid ${border}"></div>`;
78
+
79
+ // Controls — Row 1: Type + Angle, Row 2: Color Space
80
+ const selectStyle = `font:inherit;font-size:${smFont};padding:0.25rem 0.5rem;border:1px solid ${border};border-radius:4px;background:${surface}`;
81
+ let controls = '';
82
+ if (showControls) {
83
+ controls = `<div style="display:flex;flex-direction:column;gap:${smGap};font-size:${smFont}">
84
+ <div style="display:flex;flex-wrap:wrap;gap:${gap};align-items:center">
85
+ <label style="display:flex;align-items:center;gap:${xsGap}">
86
+ Type
87
+ <select class="gb-type" style="${selectStyle}">
88
+ <option value="linear"${this.#type === 'linear' ? ' selected' : ''}>Linear</option>
89
+ <option value="radial"${this.#type === 'radial' ? ' selected' : ''}>Radial</option>
90
+ </select>
91
+ </label>
92
+ <label style="display:flex;align-items:center;gap:${xsGap}${this.#type === 'radial' ? ';opacity:0.4;pointer-events:none' : ''}">
93
+ Angle
94
+ <input type="range" class="gb-angle" min="0" max="360" value="${this.#angle}" style="width:8rem;accent-color:var(--color-interactive,oklch(55% .2 260))">
95
+ <span class="gb-angle-value" style="min-width:2.5em;font-family:${mono};font-size:${xsFont}">${this.#angle}°</span>
96
+ </label>
97
+ </div>
98
+ <label style="display:inline-flex;align-items:center;gap:${xsGap}">
99
+ Color Space
100
+ <select class="gb-space" style="${selectStyle}">
101
+ <option value="oklab"${this.#interpolation === 'oklab' ? ' selected' : ''}>oklab</option>
102
+ <option value="oklch"${this.#interpolation === 'oklch' ? ' selected' : ''}>oklch</option>
103
+ <option value="srgb"${this.#interpolation === 'srgb' ? ' selected' : ''}>sRGB</option>
104
+ </select>
105
+ </label>
106
+ </div>`;
107
+ }
108
+
109
+ // Sort stops by position for display
110
+ const sorted = [...this.#stops].map((s, i) => ({ ...s, origIndex: i })).sort((a, b) => a.position - b.position);
111
+
112
+ // Stop editors — horizontal wrapping row
113
+ const stopRows = sorted.map((stop) => {
114
+ const i = stop.origIndex;
115
+ return `<div style="display:flex;align-items:center;gap:0.375rem" data-stop="${i}">
116
+ <input type="color" value="${stop.color}" class="gb-stop-color" data-i="${i}"
117
+ style="width:1.75rem;height:1.75rem;padding:0;border:1px solid ${border};border-radius:4px;cursor:pointer;flex-shrink:0">
118
+ <input type="range" min="0" max="100" value="${stop.position}" class="gb-stop-pos-range" data-i="${i}"
119
+ style="flex:1;min-width:3rem;max-width:8rem;accent-color:${stop.color}">
120
+ <span style="font-family:${mono};font-size:${xsFont};min-width:2.5em;text-align:right" class="gb-stop-pos-label" data-i="${i}">${stop.position}%</span>
121
+ ${this.#stops.length > 2 ? `<button type="button" class="gb-remove" data-i="${i}"
122
+ style="all:unset;cursor:pointer;font-size:1rem;color:${muted};padding:0 0.25rem" title="Remove stop">&times;</button>` : ''}
123
+ </div>`;
124
+ }).join('');
125
+
126
+ const addBtn = `<button type="button" class="gb-add"
127
+ style="all:unset;cursor:pointer;font-size:${smFont};color:var(--color-interactive,oklch(55% .2 260));font-weight:600">+ Add Stop</button>`;
128
+
129
+
130
+ this.innerHTML = `
131
+ <div style="display:flex;flex-direction:column;gap:${gap}">
132
+ ${preview}
133
+ ${controls}
134
+ <div style="display:flex;flex-direction:column;gap:${xsGap}">
135
+ <span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.05em">Color Stops</span>
136
+ ${stopRows}
137
+ ${addBtn}
138
+ </div>
139
+ <div style="display:flex;flex-direction:column;gap:${xsGap}">
140
+ <span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.05em">Code</span>
141
+ <div style="display:flex;align-items:start;gap:${xsGap}">
142
+ <div style="flex:1;font-family:${mono};font-size:${xsFont};padding:${smGap};background:var(--color-surface-raised,#f5f5f5);border-radius:${radius};word-break:break-all;color:var(--color-text,#222)" class="gb-css-output">${css}</div>
143
+ ${showExport ? `<button type="button" class="gb-copy"
144
+ style="all:unset;cursor:pointer;font-size:${xsFont};padding:0.35rem 0.75rem;border:1px solid ${border};border-radius:4px;background:${surface};white-space:nowrap;flex-shrink:0">Copy CSS</button>` : ''}
145
+ </div>
146
+ </div>
147
+ </div>
148
+ `;
149
+
150
+ this.#wire();
151
+ }
152
+
153
+ #wire() {
154
+ // Type select
155
+ this.querySelector('.gb-type')?.addEventListener('change', (e) => {
156
+ const t = /** @type {HTMLSelectElement} */ (e.target);
157
+ this.#type = t.value;
158
+ this.#render();
159
+ this.#emit();
160
+ });
161
+
162
+ // Angle slider
163
+ const angleInput = this.querySelector('.gb-angle');
164
+ const angleValue = this.querySelector('.gb-angle-value');
165
+ angleInput?.addEventListener('input', (e) => {
166
+ const t = /** @type {HTMLInputElement} */ (e.target);
167
+ this.#angle = Number(t.value);
168
+ if (angleValue) angleValue.textContent = `${this.#angle}°`;
169
+ // Live update preview without full re-render
170
+ const preview = /** @type {HTMLElement | null} */ (this.querySelector('.gb-preview'));
171
+ if (preview) preview.style.background = this.css;
172
+ this.#emit();
173
+ });
174
+
175
+ // Interpolation select
176
+ this.querySelector('.gb-space')?.addEventListener('change', (e) => {
177
+ const t = /** @type {HTMLSelectElement} */ (e.target);
178
+ this.#interpolation = t.value;
179
+ this.#render();
180
+ this.#emit();
181
+ });
182
+
183
+ // Stop color inputs
184
+ this.querySelectorAll('.gb-stop-color').forEach((input) => {
185
+ input.addEventListener('input', (e) => {
186
+ const t = /** @type {HTMLInputElement} */ (e.target);
187
+ const i = Number(t.dataset.i);
188
+ this.#stops[i].color = t.value;
189
+ this.#updatePreview();
190
+ this.#emit();
191
+ });
192
+ });
193
+
194
+ // Stop position range sliders
195
+ this.querySelectorAll('.gb-stop-pos-range').forEach((input) => {
196
+ input.addEventListener('input', (e) => {
197
+ const t = /** @type {HTMLInputElement} */ (e.target);
198
+ const i = Number(t.dataset.i);
199
+ const val = Math.max(0, Math.min(100, Number(t.value) || 0));
200
+ this.#stops[i].position = val;
201
+ const label = this.querySelector(`.gb-stop-pos-label[data-i="${i}"]`);
202
+ if (label) label.textContent = `${val}%`;
203
+ this.#updatePreview();
204
+ this.#emit();
205
+ });
206
+ });
207
+
208
+ // Remove stop buttons
209
+ this.querySelectorAll('.gb-remove').forEach((btn) => {
210
+ btn.addEventListener('click', (e) => {
211
+ const t = /** @type {HTMLElement} */ (e.target);
212
+ const i = Number(t.dataset.i);
213
+ this.#stops.splice(i, 1);
214
+ this.#render();
215
+ this.#emit();
216
+ });
217
+ });
218
+
219
+ // Add stop button
220
+ this.querySelector('.gb-add')?.addEventListener('click', () => {
221
+ // Insert a new stop at the midpoint between the last two stops
222
+ const last = this.#stops[this.#stops.length - 1];
223
+ const prev = this.#stops[this.#stops.length - 2];
224
+ const pos = Math.round((prev.position + last.position) / 2);
225
+ this.#stops.splice(this.#stops.length - 1, 0, { color: '#888888', position: pos });
226
+ this.#render();
227
+ this.#emit();
228
+ });
229
+
230
+ // Copy CSS button
231
+ this.querySelector('.gb-copy')?.addEventListener('click', (e) => {
232
+ const btn = /** @type {HTMLElement | null} */ (e.target);
233
+ if (!btn) return;
234
+ copyText(this.css, { button: btn, announceMessage: 'Gradient CSS copied' });
235
+ });
236
+ }
237
+
238
+ #updatePreview() {
239
+ const css = this.css;
240
+ const preview = /** @type {HTMLElement | null} */ (this.querySelector('.gb-preview'));
241
+ if (preview) preview.style.background = css;
242
+ const output = this.querySelector('.gb-css-output');
243
+ if (output) output.textContent = css;
244
+ }
245
+
246
+ #emit() {
247
+ this.dispatchEvent(new CustomEvent('gradient-builder:change', {
248
+ bubbles: true,
249
+ detail: {
250
+ css: this.css,
251
+ stops: [...this.#stops],
252
+ type: this.#type,
253
+ angle: this.#angle,
254
+ interpolation: this.#interpolation,
255
+ },
256
+ }));
257
+ }
258
+ }
259
+
260
+ registerComponent('gradient-builder', GradientBuilder);
261
+ export { GradientBuilder };
@@ -0,0 +1,9 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><title>Gradient Builder (Static Fallback)</title></head>
4
+ <body>
5
+ <noscript>
6
+ <p>gradient-builder is an authoring tool for composing CSS gradients with live preview and copy-to-clipboard. It requires JS; no static fallback is meaningful.</p>
7
+ </noscript>
8
+ </body>
9
+ </html>
@@ -0,0 +1,10 @@
1
+ /* gradient-builder: Interactive CSS gradient tool */
2
+
3
+ gradient-builder {
4
+ display: block;
5
+ max-inline-size: 32rem;
6
+ }
7
+
8
+ gradient-builder:not(:defined) {
9
+ display: none;
10
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "icon-set",
4
+ "type": "web-component",
5
+ "description": "Searchable grid of every icon in a set, with click-to-copy names.",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": [
9
+ "@flow"
10
+ ]
11
+ },
12
+ "attributes": [
13
+ {
14
+ "name": "set",
15
+ "kind": "host-api",
16
+ "purpose": "config",
17
+ "type": "string",
18
+ "description": "Icon set directory (default: \"lucide\")"
19
+ },
20
+ {
21
+ "name": "names",
22
+ "kind": "host-api",
23
+ "purpose": "config",
24
+ "type": "string",
25
+ "description": "Optional space/comma-separated list of icon names. Overrides the {iconBase}/{set}.json manifest fetch."
26
+ }
27
+ ],
28
+ "childAttributes": [
29
+ {
30
+ "name": "name",
31
+ "kind": "host-api",
32
+ "purpose": "config",
33
+ "type": "string",
34
+ "element": "icon-wc",
35
+ "description": "Icon filename (without .svg), passed through from each grid cell."
36
+ }
37
+ ],
38
+ "structure": []
39
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * icon-set: Searchable icon catalog/browser
3
+ *
4
+ * Renders a filterable grid of icons for a given icon set, with
5
+ * click-to-copy names. Each cell renders the icon with the `[data-icon]`
6
+ * primitive (a currentColor CSS mask), lazy-mounted so a full set does not
7
+ * fetch every SVG at once.
8
+ *
9
+ * By default it fetches {iconBase}/{set}.json — a manifest listing every
10
+ * icon name in the set. The `names` attribute bypasses that fetch entirely
11
+ * (space/comma-separated list), which is useful for tests, offline demos,
12
+ * or curated subsets where no manifest exists.
13
+ *
14
+ * @attr {string} set - Icon set directory (default: "lucide")
15
+ * @attr {string} names - Optional space/comma-separated list of icon names.
16
+ * Overrides the {iconBase}/{set}.json manifest fetch.
17
+ *
18
+ * @example
19
+ * <icon-set names="star home search settings"></icon-set>
20
+ * <icon-set set="lucide"></icon-set>
21
+ */
22
+
23
+ import { registerComponent } from '../../lib/bundle-registry.js';
24
+ import { VBElement } from '../../lib/vb-element.js';
25
+
26
+ const iconBase = () => document.documentElement.dataset.iconPath || '/cdn/icons';
27
+
28
+ class IconSet extends VBElement {
29
+ static observedAttributes = ['set', 'names'];
30
+
31
+ /** @type {IntersectionObserver | null} Lazy-mounts [data-icon] icons as cells scroll in. */
32
+ #io = null;
33
+
34
+ setup() {
35
+ this.#render();
36
+ this.#load();
37
+ }
38
+
39
+ attributeChangedCallback() {
40
+ if (this.isConnected) {
41
+ this.#render();
42
+ this.#load();
43
+ }
44
+ }
45
+
46
+ disconnectedCallback() {
47
+ this.#io?.disconnect();
48
+ this.#io = null;
49
+ super.disconnectedCallback?.();
50
+ }
51
+
52
+ get set() {
53
+ return this.getAttribute('set') || 'lucide';
54
+ }
55
+
56
+ async #load() {
57
+ const attr = this.getAttribute('names');
58
+ let names = attr ? attr.split(/[\s,]+/).filter(Boolean) : null;
59
+ if (!names) {
60
+ try {
61
+ names = await (await fetch(`${iconBase()}/${this.set}.json`)).json();
62
+ } catch {
63
+ names = [];
64
+ }
65
+ }
66
+ this.#renderGrid(names);
67
+ }
68
+
69
+ #render() {
70
+ this.innerHTML = `
71
+ <div class="icon-set">
72
+ <label class="icon-set__search">
73
+ <span class="visually-hidden">Filter icons</span>
74
+ <input type="search" placeholder="Filter ${this.set} icons…">
75
+ </label>
76
+ <ul class="icon-set__grid" data-layout="grid" role="list"></ul>
77
+ </div>`;
78
+ const input = /** @type {HTMLInputElement} */ (this.querySelector('input'));
79
+ this.listen(input, 'input', (e) => this.#filter(/** @type {HTMLInputElement} */ (e.target).value));
80
+ }
81
+
82
+ #renderGrid(names) {
83
+ const ul = this.querySelector('.icon-set__grid');
84
+ if (!ul) return;
85
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
86
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
87
+ // Cells render with an empty, correctly-sized icon slot; the real icon
88
+ // (an <i data-icon>, whose CSS mask fetches the SVG) is mounted lazily
89
+ // below. Rendering a full set (~1900 icons) up front fires ~1900 concurrent
90
+ // fetches and exhausts the browser (net::ERR_INSUFFICIENT_RESOURCES).
91
+ ul.innerHTML = names.map((n) => `
92
+ <li data-icon-name="${esc(n)}">
93
+ <button type="button" title="Copy “${esc(n)}”" data-copy="${esc(n)}">
94
+ <span class="icon-set__icon" data-name="${esc(n)}"></span>
95
+ <span>${esc(n)}</span>
96
+ </button>
97
+ </li>`).join('');
98
+ this.listen(ul, 'click', (e) => {
99
+ const btn = /** @type {HTMLElement} */ (e.target).closest('[data-copy]');
100
+ if (btn) navigator.clipboard?.writeText(/** @type {HTMLElement} */ (btn).dataset.copy ?? '');
101
+ });
102
+
103
+ // Lazy-mount the icon only when a cell nears the viewport, so only the
104
+ // handful of visible icons fetch at a time instead of the whole set at once.
105
+ const set = this.set;
106
+ this.#io?.disconnect();
107
+ this.#io = new IntersectionObserver((entries, obs) => {
108
+ for (const entry of entries) {
109
+ if (!entry.isIntersecting) continue;
110
+ const slot = /** @type {HTMLElement} */ (entry.target);
111
+ obs.unobserve(slot);
112
+ if (slot.firstElementChild) continue; // already mounted
113
+ const icon = document.createElement('i');
114
+ icon.setAttribute('data-icon', slot.dataset.name ?? '');
115
+ icon.setAttribute('data-icon-set', set);
116
+ icon.setAttribute('aria-hidden', 'true');
117
+ slot.appendChild(icon);
118
+ }
119
+ }, { rootMargin: '400px' });
120
+ for (const slot of ul.querySelectorAll('.icon-set__icon')) this.#io.observe(slot);
121
+ }
122
+
123
+ #filter(q) {
124
+ const term = q.trim().toLowerCase();
125
+ for (const li of this.querySelectorAll('[data-icon-name]')) {
126
+ li.hidden = !!term && !(/** @type {HTMLElement} */ (li).dataset.iconName ?? '').toLowerCase().includes(term);
127
+ }
128
+ }
129
+ }
130
+
131
+ registerComponent('icon-set', IconSet);
132
+
133
+ export { IconSet };
@@ -0,0 +1,36 @@
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>Icon Set — Static Fallback</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; padding: 2rem; }
9
+ ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.5rem; }
10
+ li { text-align: center; font-size: 0.75rem; color: #666; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <!--
15
+ Static fallback for <icon-set>.
16
+
17
+ Upgrade relationship:
18
+ - Without JS: a plain list of icon names remains readable, so the
19
+ catalog's contents are never hidden from no-JS readers.
20
+ - With JS: the component replaces this with a filterable grid where
21
+ each cell renders the actual icon via <icon-wc> and a click copies
22
+ the name to the clipboard.
23
+ -->
24
+
25
+ <icon-set names="star home search settings heart check">
26
+ <ul>
27
+ <li>star</li>
28
+ <li>home</li>
29
+ <li>search</li>
30
+ <li>settings</li>
31
+ <li>heart</li>
32
+ <li>check</li>
33
+ </ul>
34
+ </icon-set>
35
+ </body>
36
+ </html>
@@ -0,0 +1,82 @@
1
+ /* icon-set: Searchable icon catalog/browser with click-to-copy names */
2
+
3
+ icon-set:not(:defined) {
4
+ display: block;
5
+ }
6
+
7
+ icon-set {
8
+ display: block;
9
+ font-family: var(--font-sans, system-ui);
10
+ color: var(--color-text, #1a1a1a);
11
+ }
12
+
13
+ @scope (icon-set) {
14
+ .icon-set__search input {
15
+ inline-size: 100%;
16
+ padding: var(--size-s, 0.5rem);
17
+ border: 1px solid var(--color-border, #d4d4d8);
18
+ border-radius: var(--radius-s, 0.25rem);
19
+ font: inherit;
20
+ color: inherit;
21
+ background: var(--color-surface, #fff);
22
+ }
23
+
24
+ /* data-layout="grid" on the <ul> lets vanilla-breeze reset the block-flow
25
+ li margin (which would otherwise skew the rows); these rules override the
26
+ primitive's defaults with the catalog's own dense column sizing + gap. */
27
+ .icon-set__grid {
28
+ list-style: none;
29
+ margin: var(--size-m, 1rem) 0 0;
30
+ padding: 0;
31
+ grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
32
+ gap: var(--size-2xs, 0.25rem);
33
+ }
34
+
35
+ .icon-set__grid [hidden] {
36
+ display: none;
37
+ }
38
+
39
+ .icon-set__grid button {
40
+ inline-size: 100%;
41
+ block-size: 100%; /* fill the cell so every card is the same height */
42
+ display: grid;
43
+ align-content: start; /* icon + name anchored at the top, so icons line up */
44
+ justify-items: center;
45
+ gap: var(--size-2xs, 0.25rem);
46
+ padding: var(--size-s, 0.75rem);
47
+ background: none;
48
+ border: 1px solid transparent;
49
+ border-radius: var(--radius-s, 0.25rem);
50
+ cursor: pointer;
51
+ font-size: var(--font-size-xs, 0.75rem);
52
+ color: inherit;
53
+ }
54
+
55
+ /* Reserve two lines for the name so 1- and 2-line names produce equal-height
56
+ cards; long names wrap instead of overflowing their neighbours. */
57
+ .icon-set__grid button > span:last-child {
58
+ min-block-size: 2lh;
59
+ text-align: center;
60
+ overflow-wrap: anywhere;
61
+ }
62
+
63
+ .icon-set__grid button:hover {
64
+ border-color: var(--color-border, #d4d4d8);
65
+ background: var(--color-surface-2, #f4f4f5);
66
+ }
67
+
68
+ /* Reserve the icon box up front so the grid doesn't reflow as icons
69
+ lazily mount, and so each empty slot is observable/visible for the
70
+ IntersectionObserver. */
71
+ .icon-set__icon {
72
+ display: inline-flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ inline-size: 1.5rem;
76
+ block-size: 1.5rem;
77
+ }
78
+
79
+ .icon-set__grid [data-icon] {
80
+ font-size: 1.5rem;
81
+ }
82
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "icon-specimen",
4
+ "type": "web-component",
5
+ "description": "Curated icon-language specimen: a fixed set of icon names rendered across the project's sizing scale.",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": [
9
+ "@flow"
10
+ ]
11
+ },
12
+ "attributes": [
13
+ {
14
+ "name": "set",
15
+ "kind": "host-api",
16
+ "purpose": "config",
17
+ "type": "string",
18
+ "description": "Icon set directory (default: \"lucide\")"
19
+ },
20
+ {
21
+ "name": "names",
22
+ "kind": "host-api",
23
+ "purpose": "config",
24
+ "type": "string",
25
+ "description": "Space/comma-separated list of icon names to display (required)."
26
+ },
27
+ {
28
+ "name": "data-sizes",
29
+ "kind": "host-api",
30
+ "purpose": "config",
31
+ "type": "string",
32
+ "description": "Space-separated sizing scale, e.g. \"1rem 1.5rem 2rem\" (default)."
33
+ }
34
+ ],
35
+ "childAttributes": [
36
+ {
37
+ "name": "name",
38
+ "kind": "host-api",
39
+ "purpose": "config",
40
+ "type": "string",
41
+ "element": "icon-wc",
42
+ "description": "Icon filename (without .svg), passed through from each specimen cell."
43
+ }
44
+ ],
45
+ "structure": []
46
+ }