@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,146 @@
1
+ /**
2
+ * motion-specimen: Design token motion scale display
3
+ *
4
+ * Shows easing curves with animated preview dots, or durations as bars.
5
+ * Use `type="easing"` (default), `type="duration"`, or `type="both"`.
6
+ *
7
+ * Layout styles are inline so the component works without external CSS.
8
+ * styles.css only contains keyframes and prefers-reduced-motion overrides.
9
+ *
10
+ * @attr {string} type - "easing" (default), "duration", or "both"
11
+ * @attr {string} tokens - Comma-separated token names (defaults vary by type)
12
+ * @attr {string} prefix - CSS variable prefix (auto-set from type if omitted)
13
+ * @attr {string} duration - Animation duration for easing previews (default "1.2s")
14
+ * @attr {boolean} show-values - Show computed values (default: true)
15
+ * @attr {string} label - Optional heading label
16
+ */
17
+ import { registerComponent } from '../../lib/bundle-registry.js';
18
+ import { VBElement } from '../../lib/vb-element.js';
19
+
20
+ const DEFAULT_EASINGS = '1,2,3,4,5,in-1,in-2,in-3,out-1,out-2,out-3,out-4,out-5,elastic-1,elastic-2,squish-1,squish-2';
21
+ const DEFAULT_DURATIONS = 'instant,fast,normal,slow,slower';
22
+
23
+ const KEYFRAMES_ID = 'ms-keyframes';
24
+ const KEYFRAMES_CSS = `
25
+ @keyframes ms-slide { from { inset-inline-start: 0 } to { inset-inline-start: calc(100% - 1rem) } }
26
+ @keyframes ms-fill { 0% { transform: scaleX(0) } 60% { transform: scaleX(1) } 100% { transform: scaleX(1); opacity: 0.3 } }
27
+ @media (prefers-reduced-motion: reduce) {
28
+ motion-specimen .ms-dot { animation: none !important; inset-inline-start: calc(50% - 0.5rem) !important }
29
+ motion-specimen .ms-bar-fill { animation: none !important; transform: scaleX(0.6) !important; opacity: 0.6 !important }
30
+ }
31
+ `;
32
+
33
+ function ensureKeyframes() {
34
+ if (typeof document === 'undefined' || document.getElementById(KEYFRAMES_ID)) return;
35
+ const style = document.createElement('style');
36
+ style.id = KEYFRAMES_ID;
37
+ style.textContent = KEYFRAMES_CSS;
38
+ document.head.append(style);
39
+ }
40
+
41
+ const MONO = 'var(--font-mono, monospace)';
42
+ const MUTED = 'var(--color-text-muted, #666)';
43
+ const INTERACTIVE = 'var(--color-interactive, oklch(55% 0.2 260))';
44
+ const SUNKEN = 'var(--color-surface-sunken, #f1f1f1)';
45
+
46
+ class MotionSpecimen extends VBElement {
47
+ static observedAttributes = ['type', 'tokens', 'prefix', 'duration', 'show-values', 'label'];
48
+
49
+ setup() {
50
+ ensureKeyframes();
51
+ this.#render();
52
+ }
53
+
54
+ attributeChangedCallback() {
55
+ if (this.isConnected && this.hasAttribute('data-upgraded')) this.#render();
56
+ }
57
+
58
+ #render() {
59
+ const type = this.getAttribute('type') || 'easing';
60
+ const previewDuration = this.getAttribute('duration') || '1.2s';
61
+ const showValues = this.getAttribute('show-values') !== 'false';
62
+ const label = this.getAttribute('label') || '';
63
+
64
+ let html = '';
65
+ if (label) html += `<p style="font-weight:600;margin:0 0 var(--size-s,0.75rem)">${label}</p>`;
66
+
67
+ if (type === 'easing' || type === 'both') {
68
+ html += this.#renderEasingSection(previewDuration, showValues);
69
+ }
70
+ if (type === 'duration' || type === 'both') {
71
+ if (type === 'both') html += `<div aria-hidden="true" style="block-size:var(--size-m,1rem)"></div>`;
72
+ html += this.#renderDurationSection(showValues);
73
+ }
74
+
75
+ this.innerHTML = html;
76
+
77
+ if (showValues) {
78
+ requestAnimationFrame(() => this.#fillValues());
79
+ }
80
+ }
81
+
82
+ #rowStyle() {
83
+ return `display:grid;grid-template-columns:5rem 1fr auto;align-items:center;gap:var(--size-s,0.75rem);min-block-size:1.75rem`;
84
+ }
85
+
86
+ #nameStyle() {
87
+ return `font-family:${MONO};font-size:var(--font-size-sm,0.875rem);color:${MUTED};text-align:end`;
88
+ }
89
+
90
+ #valueStyle() {
91
+ return `font-family:${MONO};font-size:var(--font-size-xs,0.75rem);color:${MUTED};font-variant-numeric:tabular-nums;white-space:nowrap`;
92
+ }
93
+
94
+ #listStyle() {
95
+ return `list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--size-2xs,0.375rem)`;
96
+ }
97
+
98
+ #renderEasingSection(previewDuration, showValues) {
99
+ const names = (this.getAttribute('tokens') || DEFAULT_EASINGS).split(',').map((s) => s.trim());
100
+ const prefix = this.getAttribute('prefix') || '--ease-';
101
+ const trackStyle = `position:relative;display:block;block-size:0.5rem;background:${SUNKEN};border-radius:999px`;
102
+ const dotStyle = `position:absolute;inset-block-start:50%;inset-inline-start:0;inline-size:1rem;block-size:1rem;background:${INTERACTIVE};border-radius:50%;transform:translateY(-50%)`;
103
+
104
+ const rows = names.map((name) => {
105
+ const varName = `${prefix}${name}`;
106
+ return `<li class="ms-row ms-row-ease" role="listitem" style="${this.#rowStyle()}">
107
+ <span style="${this.#nameStyle()}">${name}</span>
108
+ <span style="${trackStyle}" aria-hidden="true">
109
+ <span class="ms-dot" style="${dotStyle};animation: ms-slide ${previewDuration} var(${varName}) infinite alternate"></span>
110
+ </span>
111
+ ${showValues ? `<span class="ms-value" data-var="${varName}" style="${this.#valueStyle()}"></span>` : ''}
112
+ </li>`;
113
+ }).join('');
114
+ return `<ul class="ms-list" role="list" aria-label="Easing scale" style="${this.#listStyle()}">${rows}</ul>`;
115
+ }
116
+
117
+ #renderDurationSection(showValues) {
118
+ const names = (this.getAttribute('tokens') || DEFAULT_DURATIONS).split(',').map((s) => s.trim());
119
+ const prefix = this.getAttribute('prefix') || '--duration-';
120
+ const barStyle = `position:relative;display:block;block-size:0.5rem;background:${SUNKEN};border-radius:999px;overflow:hidden`;
121
+ const fillStyle = `position:absolute;inset-block:0;inset-inline-start:0;inline-size:100%;background:${INTERACTIVE};transform-origin:left center;animation-name:ms-fill;animation-iteration-count:infinite;animation-timing-function:linear`;
122
+
123
+ const rows = names.map((name) => {
124
+ const varName = `${prefix}${name}`;
125
+ return `<li class="ms-row ms-row-dur" role="listitem" style="${this.#rowStyle()}">
126
+ <span style="${this.#nameStyle()}">${name}</span>
127
+ <span style="${barStyle}" aria-hidden="true">
128
+ <span class="ms-bar-fill" style="${fillStyle};animation-duration: var(${varName})"></span>
129
+ </span>
130
+ ${showValues ? `<span class="ms-value" data-var="${varName}" style="${this.#valueStyle()}"></span>` : ''}
131
+ </li>`;
132
+ }).join('');
133
+ return `<ul class="ms-list" role="list" aria-label="Duration scale" style="${this.#listStyle()}">${rows}</ul>`;
134
+ }
135
+
136
+ #fillValues() {
137
+ this.querySelectorAll('.ms-value').forEach((el) => {
138
+ const v = el.getAttribute('data-var');
139
+ if (!v) return;
140
+ const computed = getComputedStyle(this).getPropertyValue(v).trim();
141
+ el.textContent = computed || '—';
142
+ });
143
+ }
144
+ }
145
+
146
+ registerComponent('motion-specimen', MotionSpecimen);
@@ -0,0 +1,13 @@
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>Motion Specimen (Static Fallback)</title>
7
+ </head>
8
+ <body>
9
+ <h1>Motion Specimen</h1>
10
+ <noscript><p>This component works best with JavaScript enabled.</p></noscript>
11
+ <motion-specimen></motion-specimen>
12
+ </body>
13
+ </html>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * motion-specimen: @keyframes and prefers-reduced-motion overrides.
3
+ * All layout styles are inline in logic.js so the component works without
4
+ * external CSS. Keyframes must be global to be referenced by animation: in
5
+ * inline styles.
6
+ */
7
+
8
+ @keyframes ms-slide {
9
+ from { inset-inline-start: 0; }
10
+ to { inset-inline-start: calc(100% - 1rem); }
11
+ }
12
+
13
+ @keyframes ms-fill {
14
+ 0% { transform: scaleX(0); }
15
+ 60% { transform: scaleX(1); }
16
+ 100% { transform: scaleX(1); opacity: 0.3; }
17
+ }
18
+
19
+ @media (prefers-reduced-motion: reduce) {
20
+ motion-specimen .ms-dot {
21
+ animation: none !important;
22
+ inset-inline-start: calc(50% - 0.5rem) !important;
23
+ }
24
+ motion-specimen .ms-bar-fill {
25
+ animation: none !important;
26
+ transform: scaleX(0.6) !important;
27
+ opacity: 0.6 !important;
28
+ }
29
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Palette generation algorithms.
3
+ * All functions accept HSL values and return arrays of hex colors with names.
4
+ * Uses hue rotation in HSL space — same principle as the OKLCH harmonics
5
+ * in color-mix.css but computed in JS for dynamic generation.
6
+ */
7
+ import { hexToHsl, hslToHex } from '../color-picker/_color-utils.js';
8
+
9
+ /** Wrap hue to 0-360 range */
10
+ const wrap = (h) => ((h % 360) + 360) % 360;
11
+
12
+ /**
13
+ * Complementary: seed + opposite on the color wheel
14
+ * @param {number} h - Hue 0-360
15
+ * @param {number} s - Saturation 0-100
16
+ * @param {number} l - Lightness 0-100
17
+ * @returns {{ colors: string[], names: string[] }}
18
+ */
19
+ export function complementary(h, s, l) {
20
+ return {
21
+ colors: [hslToHex(h, s, l), hslToHex(wrap(h + 180), s, l)],
22
+ names: ['Base', 'Complement'],
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Analogous: three adjacent hues at ±30°
28
+ */
29
+ export function analogous(h, s, l) {
30
+ return {
31
+ colors: [
32
+ hslToHex(wrap(h - 30), s, l),
33
+ hslToHex(h, s, l),
34
+ hslToHex(wrap(h + 30), s, l),
35
+ ],
36
+ names: ['Analog A', 'Base', 'Analog B'],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Triadic: three hues evenly spaced at 120°
42
+ */
43
+ export function triadic(h, s, l) {
44
+ return {
45
+ colors: [
46
+ hslToHex(h, s, l),
47
+ hslToHex(wrap(h + 120), s, l),
48
+ hslToHex(wrap(h + 240), s, l),
49
+ ],
50
+ names: ['Base', 'Triad A', 'Triad B'],
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Split-complementary: seed + two hues flanking the complement at ±30°
56
+ */
57
+ export function splitComplementary(h, s, l) {
58
+ return {
59
+ colors: [
60
+ hslToHex(h, s, l),
61
+ hslToHex(wrap(h + 150), s, l),
62
+ hslToHex(wrap(h + 210), s, l),
63
+ ],
64
+ names: ['Base', 'Split A', 'Split B'],
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Tetradic (square): four hues evenly spaced at 90°
70
+ */
71
+ export function tetradic(h, s, l) {
72
+ return {
73
+ colors: [
74
+ hslToHex(h, s, l),
75
+ hslToHex(wrap(h + 90), s, l),
76
+ hslToHex(wrap(h + 180), s, l),
77
+ hslToHex(wrap(h + 270), s, l),
78
+ ],
79
+ names: ['Base', 'Tetrad A', 'Tetrad B', 'Tetrad C'],
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Monochromatic: 11-step lightness ramp matching the 50-950 token scale.
85
+ * Preserves hue and adjusts saturation slightly at extremes for natural feel.
86
+ */
87
+ const MONO_STEPS = [
88
+ { label: '50', l: 96 },
89
+ { label: '100', l: 90 },
90
+ { label: '200', l: 80 },
91
+ { label: '300', l: 70 },
92
+ { label: '400', l: 60 },
93
+ { label: '500', l: 50 },
94
+ { label: '600', l: 40 },
95
+ { label: '700', l: 30 },
96
+ { label: '800', l: 22 },
97
+ { label: '900', l: 15 },
98
+ { label: '950', l: 10 },
99
+ ];
100
+
101
+ export function monochromatic(h, s, _l) {
102
+ const colors = [];
103
+ const names = [];
104
+ for (const step of MONO_STEPS) {
105
+ // Desaturate slightly at light/dark extremes for a natural scale
106
+ const satAdj = step.l > 85 || step.l < 20 ? Math.max(s * 0.6, 5) : s;
107
+ colors.push(hslToHex(h, Math.round(satAdj), step.l));
108
+ names.push(step.label);
109
+ }
110
+ return { colors, names };
111
+ }
112
+
113
+ /** Algorithm lookup table */
114
+ const algorithms = {
115
+ complementary,
116
+ analogous,
117
+ triadic,
118
+ 'split-complementary': splitComplementary,
119
+ tetradic,
120
+ monochromatic,
121
+ };
122
+
123
+ /**
124
+ * Generate a palette from a hex seed and harmony name.
125
+ * @param {string} hex - Seed color as hex string
126
+ * @param {string} harmony - Algorithm name
127
+ * @returns {{ colors: string[], names: string[] }}
128
+ */
129
+ export function generatePalette(hex, harmony) {
130
+ const { h, s, l } = hexToHsl(hex);
131
+ const fn = algorithms[harmony] || algorithms.complementary;
132
+ return fn(h, s, l);
133
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "palette-generator",
4
+ "type": "web-component",
5
+ "description": "Generate color palettes from a seed color",
6
+ "attributes": [
7
+ {
8
+ "name": "seed",
9
+ "kind": "host-api",
10
+ "purpose": "config",
11
+ "type": "string",
12
+ "description": "Hex seed color (e.g. \"#6366f1\"). Overridden by child input if present"
13
+ },
14
+ {
15
+ "name": "harmony",
16
+ "kind": "host-api",
17
+ "purpose": "config",
18
+ "type": "string",
19
+ "description": "Algorithm: complementary, analogous, triadic, split-complementary, tetradic, monochromatic"
20
+ },
21
+ {
22
+ "name": "include-seed",
23
+ "kind": "host-api",
24
+ "purpose": "config",
25
+ "type": "boolean",
26
+ "description": "Whether the seed appears in the palette (default: true, implicit)"
27
+ },
28
+ {
29
+ "name": "show-export",
30
+ "kind": "host-api",
31
+ "purpose": "config",
32
+ "type": "boolean",
33
+ "description": "Show Copy Hex / Copy CSS toolbar below the palette"
34
+ },
35
+ {
36
+ "name": "layout",
37
+ "kind": "host-api",
38
+ "purpose": "config",
39
+ "type": "string",
40
+ "description": "Inherited: \"inline\" (default), \"grid\", \"list\""
41
+ },
42
+ {
43
+ "name": "size",
44
+ "kind": "host-api",
45
+ "purpose": "config",
46
+ "type": "string",
47
+ "description": "Inherited: \"sm\", \"md\" (default), \"lg\""
48
+ },
49
+ {
50
+ "name": "show-values",
51
+ "kind": "host-api",
52
+ "purpose": "config",
53
+ "type": "boolean",
54
+ "description": "Inherited: always show hex values on swatches"
55
+ },
56
+ {
57
+ "name": "show-names",
58
+ "kind": "host-api",
59
+ "purpose": "config",
60
+ "type": "boolean",
61
+ "description": "Inherited: show name labels"
62
+ },
63
+ {
64
+ "name": "role",
65
+ "kind": "native",
66
+ "purpose": "config"
67
+ }
68
+ ],
69
+ "events": [
70
+ {
71
+ "name": "color-palette:select"
72
+ },
73
+ {
74
+ "name": "palette-generator:generate"
75
+ }
76
+ ],
77
+ "childAttributes": [],
78
+ "structure": []
79
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * palette-generator: Generate color palettes from a seed color
3
+ *
4
+ * Extends color-palette with algorithmic palette generation.
5
+ * Accepts a seed color (via attribute or child input/color-picker)
6
+ * and a harmony type to produce harmonious color swatches.
7
+ *
8
+ * @attr {string} seed - Hex seed color (e.g. "#6366f1"). Overridden by child input if present
9
+ * @attr {string} harmony - Algorithm: complementary, analogous, triadic, split-complementary, tetradic, monochromatic
10
+ * @attr {boolean} include-seed - Whether the seed appears in the palette (default: true, implicit)
11
+ * @attr {boolean} show-export - Show Copy Hex / Copy CSS toolbar below the palette
12
+ * @attr {string} layout - Inherited: "inline" (default), "grid", "list"
13
+ * @attr {string} size - Inherited: "sm", "md" (default), "lg"
14
+ * @attr {boolean} show-values - Inherited: always show hex values on swatches
15
+ * @attr {boolean} show-names - Inherited: show name labels
16
+ *
17
+ * @fires palette-generator:generate - When palette is computed, detail: { colors, harmony, seed }
18
+ * @fires color-palette:select - Inherited: when a swatch is clicked
19
+ *
20
+ * @example
21
+ * <palette-generator seed="#6366f1" harmony="triadic" show-values></palette-generator>
22
+ *
23
+ * @example Interactive with color-picker
24
+ * <palette-generator harmony="analogous" show-export>
25
+ * <color-picker><input type="color" value="#6366f1"></color-picker>
26
+ * </palette-generator>
27
+ */
28
+ import { registerComponent } from '../../lib/bundle-registry.js';
29
+ import { VBElement } from '../../lib/vb-element.js';
30
+ import { copyText } from '../../utils/copy-init.js';
31
+ import { generatePalette } from './_palette-utils.js';
32
+ import { hexToRgb } from '../color-picker/_color-utils.js';
33
+
34
+ class PaletteGenerator extends VBElement {
35
+ static observedAttributes = ['seed', 'harmony', 'include-seed', 'show-export',
36
+ 'layout', 'size', 'show-values', 'show-names'];
37
+
38
+ /** @type {HTMLElement|null} */
39
+ #pickerEl = null;
40
+ /** @type {string} */
41
+ #currentSeed = '';
42
+ /** @type {string[]} */
43
+ #colors = [];
44
+ /** @type {string[]} */
45
+ #names = [];
46
+
47
+ setup() {
48
+ // Find child picker — do NOT detach it
49
+ this.#pickerEl = this.querySelector('color-picker') || this.querySelector('input[type="color"]');
50
+
51
+ // Resolve seed
52
+ this.#currentSeed = this.#resolveSeed();
53
+ if (!this.#currentSeed) return;
54
+
55
+ this.#render();
56
+ this.#wireInteractive();
57
+ this.#emitGenerate();
58
+ }
59
+
60
+ attributeChangedCallback() {
61
+ if (this.isConnected && this.hasAttribute('data-upgraded')) {
62
+ this.#currentSeed = this.#resolveSeed();
63
+ if (this.#currentSeed) this.#render();
64
+ }
65
+ }
66
+
67
+ /** Extract seed hex from the picker element or seed attribute */
68
+ #resolveSeed() {
69
+ if (this.#pickerEl) {
70
+ const cp = /** @type {any} */ (this.#pickerEl);
71
+ if (cp.tagName === 'COLOR-PICKER') {
72
+ // Try upgraded getter first, fall back to inner input
73
+ const val = cp.value;
74
+ if (val && val !== '#000000') return val;
75
+ const input = /** @type {HTMLInputElement | null} */ (cp.querySelector('input[type="color"]'));
76
+ return input?.value || val || '';
77
+ }
78
+ return cp.value || '';
79
+ }
80
+ return this.getAttribute('seed') || '';
81
+ }
82
+
83
+ /** Render the full component: picker area + swatches + export toolbar */
84
+ #render() {
85
+ const harmony = this.getAttribute('harmony') || 'complementary';
86
+ const layout = this.getAttribute('layout') || 'inline';
87
+ const size = this.getAttribute('size') || 'md';
88
+ const showValues = this.hasAttribute('show-values');
89
+ const showNames = this.hasAttribute('show-names');
90
+ const showExport = this.hasAttribute('show-export');
91
+
92
+ const { colors, names } = generatePalette(this.#currentSeed, harmony);
93
+ this.#colors = colors;
94
+ this.#names = names;
95
+
96
+ const sizes = { sm: 48, md: 80, lg: 120 };
97
+ const px = sizes[size] || 80;
98
+
99
+ // Build swatch HTML (same rendering as color-palette)
100
+ let containerStyle = `display:flex;flex-wrap:wrap;gap:var(--size-xs,0.5rem)`;
101
+ if (layout === 'grid') {
102
+ containerStyle = `display:grid;grid-template-columns:repeat(auto-fill,minmax(${px}px,1fr));gap:var(--size-xs,0.5rem)`;
103
+ } else if (layout === 'list') {
104
+ containerStyle = `display:flex;flex-direction:column;gap:var(--size-xs,0.5rem)`;
105
+ }
106
+
107
+ const swatches = colors.map((color, i) => {
108
+ const name = names[i] || '';
109
+ const contrast = this.#contrastColor(color);
110
+ const wrapStyle = layout === 'list'
111
+ ? `display:flex;flex-direction:row;align-items:center;gap:0.75rem`
112
+ : `display:flex;flex-direction:column;align-items:center;gap:0.25rem;max-inline-size:${px}px`;
113
+ const boxSize = layout === 'list' ? 36 : px;
114
+ const formatted = color.length > 12 ? color.slice(0, 12) + '\u2026' : color;
115
+
116
+ return `<div class="swatch-wrap" role="listitem" style="${wrapStyle}">
117
+ <button type="button" class="color-box" data-index="${i}"
118
+ style="background:${color};color:${contrast};width:${boxSize}px;height:${boxSize}px;border:1px solid oklch(0% 0 0/0.15);border-radius:var(--radius-s,0.25rem);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-family:var(--font-mono,monospace);position:relative;overflow:hidden;flex-shrink:0"
119
+ title="Click to copy${name ? ': ' + name : ''}"
120
+ aria-label="${name || 'Color ' + (i + 1)}: ${color}">
121
+ <span class="color-value" style="font-size:0.625rem;line-height:1.2;opacity:${showValues ? '1' : '0'};text-align:center;padding:2px 4px;word-break:break-all;transition:opacity 0.15s ease">${formatted}</span>
122
+ </button>
123
+ ${showNames && name ? `<span style="font-size:var(--font-size-xs,0.75rem);color:var(--color-text-muted,#666);text-align:center;max-inline-size:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${name}</span>` : ''}
124
+ </div>`;
125
+ }).join('');
126
+
127
+ // Build export toolbar HTML
128
+ let exportHTML = '';
129
+ if (showExport) {
130
+ exportHTML = `<div class="pg-export" role="toolbar" aria-label="Export palette" style="display:flex;gap:0.5rem;margin-block-start:var(--size-xs,0.5rem)">
131
+ <button type="button" class="pg-copy-hex" style="padding:0.25rem 0.75rem;border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);background:var(--color-surface,#fff);color:var(--color-text,#333);cursor:pointer;font-size:var(--font-size-xs,0.75rem);font-family:inherit">Copy Hex</button>
132
+ <button type="button" class="pg-copy-css" style="padding:0.25rem 0.75rem;border:1px solid var(--color-border,#ccc);border-radius:var(--radius-s,0.25rem);background:var(--color-surface,#fff);color:var(--color-text,#333);cursor:pointer;font-size:var(--font-size-xs,0.75rem);font-family:inherit">Copy CSS</button>
133
+ </div>`;
134
+ }
135
+
136
+ // Remove everything except the picker, then insert palette + export
137
+ const pickerEl = this.#pickerEl;
138
+ for (const child of [...this.children]) {
139
+ if (child === pickerEl) continue;
140
+ if (child.classList?.contains('pg-seed-label')) continue;
141
+ child.remove();
142
+ }
143
+
144
+ // Add a label before the picker (once only — never move the picker)
145
+ if (pickerEl && !this.querySelector('.pg-seed-label')) {
146
+ const label = document.createElement('span');
147
+ label.className = 'pg-seed-label';
148
+ label.textContent = 'Seed Color';
149
+ pickerEl.before(label);
150
+ }
151
+
152
+ // Insert palette after picker (or at start if no picker)
153
+ const paletteDiv = document.createElement('div');
154
+ paletteDiv.className = `palette ${layout}`;
155
+ paletteDiv.setAttribute('role', 'list');
156
+ paletteDiv.setAttribute('aria-label', 'Color palette');
157
+ paletteDiv.style.cssText = containerStyle;
158
+ paletteDiv.innerHTML = swatches;
159
+
160
+ if (pickerEl) {
161
+ pickerEl.after(paletteDiv);
162
+ } else {
163
+ this.prepend(paletteDiv);
164
+ }
165
+
166
+ // Insert export toolbar
167
+ if (exportHTML) {
168
+ const tmp = document.createElement('div');
169
+ tmp.innerHTML = exportHTML;
170
+ if (tmp.firstElementChild) this.append(tmp.firstElementChild);
171
+ }
172
+
173
+ // Wire swatch interactions
174
+ this.#wireSwatches(paletteDiv, colors, names, showValues);
175
+
176
+ // Wire export buttons
177
+ if (showExport) {
178
+ this.#wireExport();
179
+ }
180
+ }
181
+
182
+ /** Wire click-to-copy and hover on swatches */
183
+ #wireSwatches(container, colors, names, showValues) {
184
+ container.querySelectorAll('.color-box').forEach((el) => {
185
+ const btn = /** @type {HTMLElement} */ (el);
186
+ if (!showValues) {
187
+ btn.addEventListener('pointerenter', () => {
188
+ const val = /** @type {HTMLElement | null} */ (btn.querySelector('.color-value'));
189
+ if (val) val.style.opacity = '1';
190
+ });
191
+ btn.addEventListener('pointerleave', () => {
192
+ const val = /** @type {HTMLElement | null} */ (btn.querySelector('.color-value'));
193
+ if (val) val.style.opacity = '0';
194
+ });
195
+ }
196
+
197
+ btn.addEventListener('click', () => {
198
+ const idx = Number(btn.dataset.index);
199
+ const color = colors[idx];
200
+ const name = names[idx] || '';
201
+ copyText(color, { button: btn, announceMessage: 'Color copied' });
202
+ this.dispatchEvent(new CustomEvent('color-palette:select', {
203
+ bubbles: true, detail: { color, name, index: idx },
204
+ }));
205
+ btn.style.outline = '3px solid currentColor';
206
+ btn.style.outlineOffset = '2px';
207
+ setTimeout(() => { btn.style.outline = ''; btn.style.outlineOffset = ''; }, 600);
208
+ });
209
+ });
210
+ }
211
+
212
+ /** Wire up interactive picker for live regeneration */
213
+ #wireInteractive() {
214
+ const cp = this.#pickerEl;
215
+ if (!cp) return;
216
+
217
+ if (cp.tagName === 'COLOR-PICKER') {
218
+ this.listen(cp, 'color-picker:change', (e) => {
219
+ this.#currentSeed = /** @type {CustomEvent} */ (e).detail.hex;
220
+ this.#render();
221
+ this.#emitGenerate();
222
+ });
223
+ } else {
224
+ this.listen(cp, 'input', () => {
225
+ this.#currentSeed = /** @type {any} */ (cp).value;
226
+ this.#render();
227
+ this.#emitGenerate();
228
+ });
229
+ }
230
+ }
231
+
232
+ /** Wire export toolbar buttons */
233
+ #wireExport() {
234
+ const hexBtn = this.querySelector('.pg-copy-hex');
235
+ const cssBtn = this.querySelector('.pg-copy-css');
236
+
237
+ if (hexBtn) {
238
+ hexBtn.addEventListener('click', () => {
239
+ copyText(this.#colors.join(', '), {
240
+ button: /** @type {HTMLElement} */ (hexBtn),
241
+ announceMessage: 'Hex values copied',
242
+ });
243
+ });
244
+ }
245
+
246
+ if (cssBtn) {
247
+ cssBtn.addEventListener('click', () => {
248
+ const harmony = this.getAttribute('harmony') || 'complementary';
249
+ const css = this.#formatCSSExport(harmony);
250
+ copyText(css, {
251
+ button: /** @type {HTMLElement} */ (cssBtn),
252
+ announceMessage: 'CSS variables copied',
253
+ });
254
+ });
255
+ }
256
+ }
257
+
258
+ /** Return black or white depending on perceived lightness */
259
+ #contrastColor(color) {
260
+ if (color.startsWith('#')) {
261
+ const { r, g, b } = hexToRgb(color);
262
+ const lum = 0.2126 * (r / 255) + 0.7152 * (g / 255) + 0.0722 * (b / 255);
263
+ return lum > 0.4 ? '#000' : '#fff';
264
+ }
265
+ return '#000';
266
+ }
267
+
268
+ /** Format colors as CSS custom properties */
269
+ #formatCSSExport(harmony) {
270
+ if (harmony === 'monochromatic') {
271
+ return this.#colors.map((c, i) => `--color-seed-${this.#names[i] || i + 1}: ${c};`).join('\n');
272
+ }
273
+ return this.#colors.map((c, i) => {
274
+ const name = this.#names[i] ? this.#names[i].toLowerCase().replace(/\s+/g, '-') : `${i + 1}`;
275
+ return `--palette-${name}: ${c};`;
276
+ }).join('\n');
277
+ }
278
+
279
+ #emitGenerate() {
280
+ const harmony = this.getAttribute('harmony') || 'complementary';
281
+ this.dispatchEvent(new CustomEvent('palette-generator:generate', {
282
+ bubbles: true,
283
+ detail: { colors: [...this.#colors], harmony, seed: this.#currentSeed },
284
+ }));
285
+ }
286
+ }
287
+
288
+ registerComponent('palette-generator', PaletteGenerator);