@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,287 @@
1
+ /**
2
+ * copy-init: Clipboard copy + paste via data attributes + programmatic helpers
3
+ *
4
+ * Enhances buttons with data-copy / data-copy-target / data-paste-target
5
+ * to interact with the clipboard. Also exports copyText(), copyRich(),
6
+ * and pasteFromClipboard() for components that need clipboard access
7
+ * with VB's standard a11y + feedback semantics.
8
+ *
9
+ * @attr {string} data-copy - Static text to copy
10
+ * @attr {string} data-copy-target - CSS selector for element whose textContent to copy
11
+ * @attr {string} data-copy-attr - When paired with data-copy-target, copy the named
12
+ * attribute of the target instead of its textContent
13
+ * @attr {string} data-paste-target - CSS selector for element to receive pasted clipboard text
14
+ *
15
+ * @example Static text
16
+ * <button data-copy="npm install vanilla-breeze">Copy</button>
17
+ *
18
+ * @example Target element
19
+ * <button data-copy-target="#code-block">Copy code</button>
20
+ *
21
+ * @example Target attribute
22
+ * <output id="swatch" value="#3366cc">#3366cc</output>
23
+ * <button data-copy-target="#swatch" data-copy-attr="value">Copy hex</button>
24
+ *
25
+ * @example Paste into a target
26
+ * <input id="dest" type="text">
27
+ * <button data-paste-target="#dest">Paste</button>
28
+ *
29
+ * @example Programmatic
30
+ * import { copyText, copyRich, pasteFromClipboard } from '/src/utils/copy-init.js';
31
+ * await copyText('hello', { button: myButton });
32
+ * await copyRich({ text: '#3366cc', blob: pngBlob }, { button: myButton });
33
+ * await pasteFromClipboard(inputEl, { button: myButton });
34
+ */
35
+
36
+ const COPIED_DURATION = 1500;
37
+ const ANNOUNCE_DURATION = 1000;
38
+ const SELECTOR = '[data-copy], [data-copy-target], [data-paste-target]';
39
+ const DEFAULT_ANNOUNCE = 'Copied to clipboard';
40
+ const DEFAULT_PASTE_ANNOUNCE = 'Pasted from clipboard';
41
+
42
+ const resetTimers = new WeakMap();
43
+
44
+ /**
45
+ * Copy text to the clipboard with VB's standard feedback semantics.
46
+ *
47
+ * Performs writeText, sets data-state="copied" on the button (if given),
48
+ * announces to screen readers, and dispatches a `copy` CustomEvent from
49
+ * the button. Safe to call when the Clipboard API is unavailable or
50
+ * permission is denied — returns false silently.
51
+ *
52
+ * @param {string} text - The text to copy.
53
+ * @param {object} [options]
54
+ * @param {HTMLElement} [options.button] - Button to receive data-state and dispatch the event.
55
+ * @param {string} [options.announceMessage] - Screen-reader message. Defaults to "Copied to clipboard".
56
+ * @param {number} [options.duration] - Milliseconds to hold data-state="copied". Defaults to 1500.
57
+ * @returns {Promise<boolean>} True if the write succeeded.
58
+ */
59
+ async function copyText(text, options = {}) {
60
+ if (text == null || text === '') return false;
61
+ const { button, announceMessage = DEFAULT_ANNOUNCE, duration = COPIED_DURATION } = options;
62
+
63
+ try {
64
+ await navigator.clipboard.writeText(text);
65
+ } catch {
66
+ return false;
67
+ }
68
+
69
+ applyFeedback({ button, duration, announceMessage, eventDetail: { text } });
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Copy a multi-format payload to the clipboard.
75
+ *
76
+ * Writes a ClipboardItem with any combination of text/plain, text/html,
77
+ * and a binary blob (typically image/png). When ClipboardItem is
78
+ * unsupported or the browser rejects the specific MIME types, falls
79
+ * back to text-only copy via copyText.
80
+ *
81
+ * Common use cases: copying a color as hex text + a swatch PNG so it
82
+ * can be pasted into design tools as an actual color, or copying a
83
+ * QR code as an image alongside its encoded URL.
84
+ *
85
+ * @param {object} payload
86
+ * @param {string} [payload.text] - text/plain content
87
+ * @param {string} [payload.html] - text/html content
88
+ * @param {Blob} [payload.blob] - Arbitrary blob (e.g. image/png). MIME type comes from blob.type.
89
+ * @param {object} [options]
90
+ * @param {HTMLElement} [options.button]
91
+ * @param {string} [options.announceMessage]
92
+ * @param {number} [options.duration]
93
+ * @returns {Promise<boolean>} True if any clipboard write succeeded (rich or text fallback).
94
+ */
95
+ async function copyRich(payload, options = {}) {
96
+ const { text, html, blob } = payload ?? {};
97
+ if (!text && !html && !blob) return false;
98
+ const { button, announceMessage = DEFAULT_ANNOUNCE, duration = COPIED_DURATION } = options;
99
+
100
+ const richSupported = typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard?.write === 'function';
101
+
102
+ if (richSupported) {
103
+ /** @type {Record<string, Blob>} */
104
+ const items = {};
105
+ if (text) items['text/plain'] = new Blob([text], { type: 'text/plain' });
106
+ if (html) items['text/html'] = new Blob([html], { type: 'text/html' });
107
+ if (blob) items[blob.type] = blob;
108
+
109
+ try {
110
+ await navigator.clipboard.write([new ClipboardItem(items)]);
111
+ applyFeedback({ button, duration, announceMessage, eventDetail: { payload } });
112
+ return true;
113
+ } catch {
114
+ // Browser may support ClipboardItem but reject some MIME types
115
+ // (Safari historically restricts image types). Fall through to text.
116
+ }
117
+ }
118
+
119
+ if (text) return copyText(text, options);
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Apply the VB-standard post-write feedback: data-state, screen-reader
125
+ * announce, and a CustomEvent from the button.
126
+ * @param {{ button?: HTMLElement, duration: number, announceMessage: string, eventDetail: object, state?: string, eventName?: string }} args
127
+ */
128
+ function applyFeedback({ button, duration, announceMessage, eventDetail, state = 'copied', eventName = 'copy' }) {
129
+ if (button) {
130
+ button.dataset.state = state;
131
+ const prior = resetTimers.get(button);
132
+ if (prior) clearTimeout(prior);
133
+ resetTimers.set(button, setTimeout(() => {
134
+ delete button.dataset.state;
135
+ resetTimers.delete(button);
136
+ }, duration));
137
+
138
+ button.dispatchEvent(new CustomEvent(eventName, {
139
+ bubbles: true,
140
+ detail: eventDetail,
141
+ }));
142
+ }
143
+
144
+ announce(announceMessage, button ?? document.body);
145
+ }
146
+
147
+ /**
148
+ * Read text from the clipboard and write it into a target element.
149
+ *
150
+ * Form controls (input, textarea, select) receive `.value`; other
151
+ * elements receive `.textContent`. After writing, an `input` event
152
+ * is dispatched on the target so framework listeners notice the change.
153
+ * Sets data-state="pasted" on the button, announces to screen readers,
154
+ * and dispatches a `paste` CustomEvent from the button.
155
+ *
156
+ * Safe to call when the Clipboard API is unavailable or the user
157
+ * denies permission — returns null silently.
158
+ *
159
+ * @param {Element | null | undefined} target - Element to receive the pasted text.
160
+ * @param {object} [options]
161
+ * @param {HTMLElement} [options.button]
162
+ * @param {string} [options.announceMessage]
163
+ * @param {number} [options.duration]
164
+ * @returns {Promise<string | null>} The text that was pasted, or null on failure.
165
+ */
166
+ async function pasteFromClipboard(target, options = {}) {
167
+ const { button, announceMessage = DEFAULT_PASTE_ANNOUNCE, duration = COPIED_DURATION } = options;
168
+
169
+ let text;
170
+ try {
171
+ text = await navigator.clipboard.readText();
172
+ } catch {
173
+ return null;
174
+ }
175
+
176
+ if (target) {
177
+ if ('value' in target && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) {
178
+ target.value = text;
179
+ } else {
180
+ target.textContent = text;
181
+ }
182
+ target.dispatchEvent(new Event('input', { bubbles: true }));
183
+ }
184
+
185
+ applyFeedback({
186
+ button,
187
+ duration,
188
+ announceMessage,
189
+ eventDetail: { text },
190
+ state: 'pasted',
191
+ eventName: 'paste',
192
+ });
193
+ return text;
194
+ }
195
+
196
+ /**
197
+ * Initialize copy/paste buttons within a root element
198
+ * @param {Element|Document} root - Root element to search within
199
+ */
200
+ function initCopyButtons(root = document) {
201
+ root.querySelectorAll(SELECTOR).forEach(enhanceButton);
202
+ }
203
+
204
+ /**
205
+ * Enhance a single button with copy or paste behavior
206
+ * @param {HTMLElement} button - The button element to enhance
207
+ */
208
+ function enhanceButton(button) {
209
+ if (button.hasAttribute('data-copy-init')) return;
210
+ button.setAttribute('data-copy-init', '');
211
+
212
+ button.addEventListener('click', () => {
213
+ if (button.dataset.pasteTarget) {
214
+ const target = document.querySelector(button.dataset.pasteTarget);
215
+ if (!target) return;
216
+ pasteFromClipboard(target, { button });
217
+ return;
218
+ }
219
+
220
+ const text = getText(button);
221
+ if (!text) return;
222
+ copyText(text, { button });
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Get text to copy from a button's data attributes
228
+ * @param {HTMLElement} button
229
+ * @returns {string}
230
+ */
231
+ function getText(button) {
232
+ if (button.dataset.copy) return button.dataset.copy;
233
+
234
+ if (button.dataset.copyTarget) {
235
+ const target = document.querySelector(button.dataset.copyTarget);
236
+ if (!target) return '';
237
+ const attr = button.dataset.copyAttr;
238
+ if (attr) return target.getAttribute(attr) ?? '';
239
+ return target.textContent ?? '';
240
+ }
241
+
242
+ return '';
243
+ }
244
+
245
+ /**
246
+ * Announce a message to screen readers
247
+ * @param {string} message
248
+ * @param {Element} context - Element to append announcement to
249
+ */
250
+ function announce(message, context) {
251
+ const el = document.createElement('div');
252
+ el.setAttribute('role', 'status');
253
+ el.setAttribute('aria-live', 'polite');
254
+ el.className = 'visually-hidden';
255
+ el.textContent = message;
256
+ context.appendChild(el);
257
+ setTimeout(() => el.remove(), ANNOUNCE_DURATION);
258
+ }
259
+
260
+ // Auto-init on DOMContentLoaded
261
+ if (document.readyState === 'loading') {
262
+ document.addEventListener('DOMContentLoaded', () => initCopyButtons());
263
+ } else {
264
+ initCopyButtons();
265
+ }
266
+
267
+ // Watch for dynamically added copy buttons
268
+ const observer = new MutationObserver((mutations) => {
269
+ for (const mutation of mutations) {
270
+ for (const node of mutation.addedNodes) {
271
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
272
+
273
+ // Check the node itself
274
+ const el = /** @type {Element} */ (node);
275
+ if (el.matches(SELECTOR)) {
276
+ enhanceButton(/** @type {HTMLElement} */ (el));
277
+ }
278
+
279
+ // Check descendants
280
+ el.querySelectorAll(SELECTOR).forEach(child => enhanceButton(/** @type {HTMLElement} */ (child)));
281
+ }
282
+ }
283
+ });
284
+
285
+ observer.observe(document.documentElement, { childList: true, subtree: true });
286
+
287
+ export { initCopyButtons, copyText, copyRich, pasteFromClipboard };
@@ -0,0 +1,51 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "accessibility-specimen",
4
+ "type": "web-component",
5
+ "description": "WCAG contrast-ratio table + a11y checklist for design-system docs. Default mode: tabular display of color pairs with computed contrast ratio + AA/AAA badges. Checklist mode: slot-driven WCAG checklist with status icons.",
6
+ "htmlvalidate": {
7
+ "flow": true,
8
+ "permittedContent": [
9
+ "@flow"
10
+ ]
11
+ },
12
+ "attributes": [
13
+ {
14
+ "name": "type",
15
+ "kind": "host-api",
16
+ "purpose": "config",
17
+ "type": "string",
18
+ "description": "\"contrast\" (default) | \"checklist\""
19
+ }
20
+ ],
21
+ "events": [],
22
+ "childAttributes": [
23
+ {
24
+ "name": "data-fg",
25
+ "on": "button",
26
+ "type": "string",
27
+ "required": true,
28
+ "description": "Foreground color (hex / rgb() / named); contrast mode"
29
+ },
30
+ {
31
+ "name": "data-bg",
32
+ "on": "button",
33
+ "type": "string",
34
+ "required": true,
35
+ "description": "Background color (hex / rgb() / named); contrast mode"
36
+ },
37
+ {
38
+ "name": "data-label",
39
+ "on": "button",
40
+ "type": "string",
41
+ "description": "Display label for the pair; defaults to button text content"
42
+ },
43
+ {
44
+ "name": "data-status",
45
+ "on": "li",
46
+ "type": "string",
47
+ "description": "Checklist mode: pass | fail | warn | na (default: na)"
48
+ }
49
+ ],
50
+ "structure": []
51
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * accessibility-specimen: WCAG contrast-ratio table + a11y checklist
3
+ * for design-system docs.
4
+ *
5
+ * Two modes:
6
+ * - "contrast" (default): tabular display of foreground / background
7
+ * color pairs with computed WCAG 2.x contrast ratio + AA/AAA badges
8
+ * for normal- and large-text. Author provides pairs as direct
9
+ * `<button data-fg data-bg [data-label]>` children.
10
+ * - "checklist": author authors a `<ul>` of items with `data-status=
11
+ * pass|fail|warn|na` (or none → unset). Component normalizes the
12
+ * visual styling and adds an icon per status.
13
+ *
14
+ * @attr {string} type - "contrast" (default) | "checklist"
15
+ *
16
+ * Distinct from:
17
+ * - <semantic-palette> (palette colors with semantic roles, not pairs)
18
+ * - <token-specimen> (token-scale display, single axis)
19
+ */
20
+
21
+ import { registerComponent } from '../../lib/bundle-registry.js';
22
+ import { VBElement } from '../../lib/vb-element.js';
23
+
24
+ // ── Color parsing + WCAG 2.x contrast ─────────────────────────────────
25
+
26
+ /**
27
+ * Parse a CSS color string into [r, g, b] (0-255). Supports #hex, #hex8,
28
+ * rgb()/rgba() and named colors (via a temporary canvas trick). Returns
29
+ * null on failure so the consumer can show a "?" badge.
30
+ * @returns {[number, number, number] | null}
31
+ */
32
+ function parseColor(str) {
33
+ const s = String(str || '').trim();
34
+ if (!s) return null;
35
+ // Hex shorthand / full
36
+ let m = s.match(/^#([0-9a-f]{3,8})$/i);
37
+ if (m) {
38
+ const h = m[1];
39
+ if (h.length === 3) return [parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16)];
40
+ if (h.length === 4) return [parseInt(h[0]+h[0],16), parseInt(h[1]+h[1],16), parseInt(h[2]+h[2],16)];
41
+ if (h.length === 6) return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
42
+ if (h.length === 8) return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
43
+ }
44
+ // rgb / rgba
45
+ m = s.match(/^rgba?\s*\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/i);
46
+ if (m) return [Math.round(+m[1]), Math.round(+m[2]), Math.round(+m[3])];
47
+ // Fall back to letting the browser resolve via getComputedStyle on a probe.
48
+ // (Handles named colors, oklch(), color-mix(), etc. — but loses alpha.)
49
+ if (typeof document !== 'undefined') {
50
+ const probe = document.createElement('span');
51
+ probe.style.color = s;
52
+ probe.style.display = 'none';
53
+ document.body.appendChild(probe);
54
+ const cs = getComputedStyle(probe).color;
55
+ probe.remove();
56
+ const rgb = cs.match(/^rgba?\s*\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)/i);
57
+ if (rgb) return [Math.round(+rgb[1]), Math.round(+rgb[2]), Math.round(+rgb[3])];
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /** Linearize an sRGB channel per WCAG 2.x. */
63
+ function lin(c) {
64
+ const v = c / 255;
65
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
66
+ }
67
+
68
+ /** Relative luminance per WCAG 2.x. */
69
+ function luminance([r, g, b]) {
70
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
71
+ }
72
+
73
+ /** Contrast ratio per WCAG 2.x (returns 1..21, or null on parse failure). */
74
+ function contrastRatio(fg, bg) {
75
+ const f = parseColor(fg);
76
+ const b = parseColor(bg);
77
+ if (!f || !b) return null;
78
+ const l1 = luminance(f);
79
+ const l2 = luminance(b);
80
+ const lighter = Math.max(l1, l2);
81
+ const darker = Math.min(l1, l2);
82
+ return (lighter + 0.05) / (darker + 0.05);
83
+ }
84
+
85
+ /**
86
+ * @returns {{ aaNormal: boolean, aaLarge: boolean, aaaNormal: boolean, aaaLarge: boolean }}
87
+ */
88
+ function wcagBadges(ratio) {
89
+ if (ratio == null) return { aaNormal: false, aaLarge: false, aaaNormal: false, aaaLarge: false };
90
+ return {
91
+ aaNormal: ratio >= 4.5,
92
+ aaLarge: ratio >= 3.0,
93
+ aaaNormal: ratio >= 7.0,
94
+ aaaLarge: ratio >= 4.5,
95
+ };
96
+ }
97
+
98
+ // ── Component ─────────────────────────────────────────────────────────
99
+
100
+ class AccessibilitySpecimen extends VBElement {
101
+ static observedAttributes = ['type'];
102
+
103
+ setup() {
104
+ const type = this.getAttribute('type') || 'contrast';
105
+ if (type === 'checklist') this.#renderChecklist();
106
+ else this.#renderContrast();
107
+ }
108
+
109
+ attributeChangedCallback(_name, oldVal, newVal) {
110
+ if (!this.isConnected || oldVal === newVal) return;
111
+ this.setup();
112
+ }
113
+
114
+ // ── Contrast mode ─────────────────────────────────────────────────
115
+
116
+ #renderContrast() {
117
+ // Capture authored pairs (button[data-fg][data-bg]) before rebuilding.
118
+ const pairs = /** @type {HTMLElement[]} */ ([...this.querySelectorAll(':scope > button[data-fg][data-bg]')]).map((b) => ({
119
+ fg: b.dataset.fg,
120
+ bg: b.dataset.bg,
121
+ label: b.dataset.label || b.textContent.trim() || `${b.dataset.fg} on ${b.dataset.bg}`,
122
+ }));
123
+
124
+ const table = document.createElement('table');
125
+ table.className = 'as-contrast-table';
126
+
127
+ const thead = document.createElement('thead');
128
+ thead.innerHTML = `
129
+ <tr>
130
+ <th scope="col">Pair</th>
131
+ <th scope="col">Sample</th>
132
+ <th scope="col">Ratio</th>
133
+ <th scope="col">AA</th>
134
+ <th scope="col">AA Large</th>
135
+ <th scope="col">AAA</th>
136
+ <th scope="col">AAA Large</th>
137
+ </tr>`;
138
+ table.appendChild(thead);
139
+
140
+ const tbody = document.createElement('tbody');
141
+ if (pairs.length === 0) {
142
+ const tr = document.createElement('tr');
143
+ const td = document.createElement('td');
144
+ td.colSpan = 7;
145
+ td.className = 'as-empty';
146
+ td.textContent = 'No color pairs authored. Add <button data-fg="…" data-bg="…">Label</button> children.';
147
+ tr.appendChild(td);
148
+ tbody.appendChild(tr);
149
+ } else {
150
+ for (const p of pairs) tbody.appendChild(this.#renderPairRow(p));
151
+ }
152
+ table.appendChild(tbody);
153
+
154
+ this.replaceChildren(table);
155
+ }
156
+
157
+ #renderPairRow({ fg, bg, label }) {
158
+ const ratio = contrastRatio(fg, bg);
159
+ const badges = wcagBadges(ratio);
160
+ const ratioText = ratio == null ? '?' : `${ratio.toFixed(2)}:1`;
161
+
162
+ const tr = document.createElement('tr');
163
+
164
+ const tdLabel = document.createElement('th');
165
+ tdLabel.scope = 'row';
166
+ tdLabel.className = 'as-pair-label';
167
+ tdLabel.innerHTML = `<span class="as-swatch" style="background:${bg};border-color:${fg}"></span><span>${escapeText(label)}</span>`;
168
+ tr.appendChild(tdLabel);
169
+
170
+ const tdSample = document.createElement('td');
171
+ tdSample.className = 'as-pair-sample';
172
+ tdSample.style.background = bg;
173
+ tdSample.style.color = fg;
174
+ tdSample.textContent = 'Aa Bb 123';
175
+ tr.appendChild(tdSample);
176
+
177
+ const tdRatio = document.createElement('td');
178
+ tdRatio.className = 'as-pair-ratio';
179
+ tdRatio.textContent = ratioText;
180
+ tr.appendChild(tdRatio);
181
+
182
+ for (const key of ['aaNormal', 'aaLarge', 'aaaNormal', 'aaaLarge']) {
183
+ const td = document.createElement('td');
184
+ const ok = badges[key];
185
+ td.className = `as-badge ${ok ? 'as-pass' : 'as-fail'}`;
186
+ td.textContent = ok ? '✓' : '✗';
187
+ td.setAttribute('aria-label', ok ? 'Pass' : 'Fail');
188
+ tr.appendChild(td);
189
+ }
190
+
191
+ return tr;
192
+ }
193
+
194
+ // ── Checklist mode ────────────────────────────────────────────────
195
+
196
+ #renderChecklist() {
197
+ // Author provides a <ul> with <li data-status="pass|fail|warn|na"> items.
198
+ const list = this.querySelector(':scope > ul, :scope > ol');
199
+ if (!list) return;
200
+ list.classList.add('as-checklist');
201
+ for (const item of list.querySelectorAll(':scope > li')) {
202
+ this.#decorateChecklistItem(item);
203
+ }
204
+ }
205
+
206
+ #decorateChecklistItem(item) {
207
+ if (item.dataset.decorated === '') return;
208
+ item.dataset.decorated = '';
209
+ const status = item.dataset.status || 'na';
210
+ item.classList.add(`as-status-${status}`);
211
+
212
+ const icon = document.createElement('span');
213
+ icon.className = 'as-checklist-icon';
214
+ icon.setAttribute('aria-hidden', 'true');
215
+ icon.textContent = ({ pass: '✓', fail: '✗', warn: '!', na: '–' })[status] || '–';
216
+
217
+ const label = ({ pass: 'Pass', fail: 'Fail', warn: 'Warning', na: 'Not applicable' })[status] || 'Not applicable';
218
+ const sr = document.createElement('span');
219
+ sr.className = 'as-sr-only';
220
+ sr.textContent = `${label}: `;
221
+
222
+ item.prepend(icon, sr);
223
+ }
224
+ }
225
+
226
+ function escapeText(s) {
227
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
228
+ }
229
+
230
+ registerComponent('accessibility-specimen', AccessibilitySpecimen);
231
+
232
+ export { AccessibilitySpecimen };
@@ -0,0 +1,41 @@
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>accessibility-specimen demo</title>
7
+ <link rel="stylesheet" href="/src/main.css">
8
+ <style>
9
+ body { font-family: system-ui, sans-serif; max-inline-size: 48rem; margin-inline: auto; padding: 2rem 1rem; line-height: 1.5; }
10
+ section { margin-block: 1.5rem; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <h1>accessibility-specimen</h1>
15
+
16
+ <section>
17
+ <h2>Contrast pairs</h2>
18
+ <accessibility-specimen>
19
+ <button data-fg="#000000" data-bg="#ffffff" data-label="Black on white">Default text</button>
20
+ <button data-fg="#666" data-bg="#ffffff" data-label="Mid-gray on white">Muted text</button>
21
+ <button data-fg="#ffffff" data-bg="#0066cc" data-label="White on accent blue">Action button</button>
22
+ <button data-fg="#ffffff" data-bg="#ddd" data-label="White on light-gray">Bad pairing</button>
23
+ </accessibility-specimen>
24
+ </section>
25
+
26
+ <section>
27
+ <h2>Checklist</h2>
28
+ <accessibility-specimen type="checklist">
29
+ <ul>
30
+ <li data-status="pass">Form inputs have associated labels.</li>
31
+ <li data-status="pass">Focus indicators are visible at 2px width minimum.</li>
32
+ <li data-status="warn">Some images may be missing alt text.</li>
33
+ <li data-status="fail">Color contrast on muted text fails AA.</li>
34
+ <li data-status="na">No videos in this component (captions N/A).</li>
35
+ </ul>
36
+ </accessibility-specimen>
37
+ </section>
38
+
39
+ <script type="module" src="/src/main.js"></script>
40
+ </body>
41
+ </html>