@adia-ai/web-components 0.4.8 → 0.5.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 (112) hide show
  1. package/USAGE.md +255 -2
  2. package/components/action-list/action-list.a2ui.json +15 -1
  3. package/components/action-list/action-list.d.ts +10 -1
  4. package/components/action-list/action-list.yaml +10 -0
  5. package/components/agent-artifact/agent-artifact.a2ui.json +7 -1
  6. package/components/agent-artifact/agent-artifact.d.ts +6 -1
  7. package/components/agent-artifact/agent-artifact.yaml +4 -0
  8. package/components/agent-feedback-bar/agent-feedback-bar.a2ui.json +7 -1
  9. package/components/agent-feedback-bar/agent-feedback-bar.d.ts +6 -1
  10. package/components/agent-feedback-bar/agent-feedback-bar.yaml +4 -0
  11. package/components/agent-questions/agent-questions.a2ui.json +11 -1
  12. package/components/agent-questions/agent-questions.d.ts +8 -1
  13. package/components/agent-questions/agent-questions.yaml +7 -0
  14. package/components/agent-reasoning/agent-reasoning.a2ui.json +25 -3
  15. package/components/agent-reasoning/agent-reasoning.d.ts +20 -3
  16. package/components/agent-reasoning/agent-reasoning.yaml +15 -0
  17. package/components/agent-suggestions/agent-suggestions.a2ui.json +15 -1
  18. package/components/agent-suggestions/agent-suggestions.d.ts +10 -1
  19. package/components/agent-suggestions/agent-suggestions.yaml +10 -0
  20. package/components/agent-trace/agent-trace.a2ui.json +7 -1
  21. package/components/agent-trace/agent-trace.d.ts +6 -1
  22. package/components/agent-trace/agent-trace.yaml +4 -0
  23. package/components/canvas/canvas.yaml +9 -7
  24. package/components/chart/chart.a2ui.json +3 -0
  25. package/components/chart/chart.d.ts +2 -0
  26. package/components/chart/chart.yaml +5 -0
  27. package/components/chart-legend/chart-legend.a2ui.json +15 -1
  28. package/components/chart-legend/chart-legend.d.ts +10 -1
  29. package/components/chart-legend/chart-legend.yaml +10 -0
  30. package/components/chat-thread/chat-thread.a2ui.json +11 -1
  31. package/components/chat-thread/chat-thread.d.ts +8 -1
  32. package/components/chat-thread/chat-thread.yaml +7 -0
  33. package/components/code/code.a2ui.json +36 -7
  34. package/components/code/code.d.ts +30 -0
  35. package/components/code/code.yaml +29 -6
  36. package/components/color-picker/class.js +59 -1
  37. package/components/color-picker/color-picker.a2ui.json +34 -0
  38. package/components/color-picker/color-picker.d.ts +70 -8
  39. package/components/color-picker/color-picker.yaml +49 -0
  40. package/components/command/command.a2ui.json +11 -1
  41. package/components/command/command.d.ts +8 -1
  42. package/components/command/command.yaml +7 -0
  43. package/components/demo-toggle/demo-toggle.a2ui.json +7 -1
  44. package/components/demo-toggle/demo-toggle.d.ts +6 -1
  45. package/components/demo-toggle/demo-toggle.yaml +4 -0
  46. package/components/heatmap/heatmap.a2ui.json +11 -2
  47. package/components/heatmap/heatmap.d.ts +6 -0
  48. package/components/heatmap/heatmap.yaml +17 -2
  49. package/components/link/link.a2ui.json +11 -1
  50. package/components/link/link.d.ts +8 -1
  51. package/components/link/link.yaml +7 -0
  52. package/components/list/list.a2ui.json +11 -1
  53. package/components/list/list.d.ts +8 -1
  54. package/components/list/list.yaml +7 -0
  55. package/components/menu/menu.a2ui.json +11 -1
  56. package/components/menu/menu.d.ts +8 -1
  57. package/components/menu/menu.yaml +7 -0
  58. package/components/nav/nav.a2ui.json +15 -1
  59. package/components/nav/nav.d.ts +10 -1
  60. package/components/nav/nav.yaml +10 -0
  61. package/components/nav-group/nav-group.a2ui.json +11 -1
  62. package/components/nav-group/nav-group.d.ts +8 -1
  63. package/components/nav-group/nav-group.yaml +7 -0
  64. package/components/nav-item/nav-item.a2ui.json +15 -1
  65. package/components/nav-item/nav-item.d.ts +10 -1
  66. package/components/nav-item/nav-item.yaml +10 -0
  67. package/components/noodles/noodles.a2ui.json +46 -2
  68. package/components/noodles/noodles.d.ts +28 -2
  69. package/components/noodles/noodles.yaml +32 -0
  70. package/components/otp-input/otp-input.a2ui.json +14 -2
  71. package/components/otp-input/otp-input.d.ts +11 -0
  72. package/components/otp-input/otp-input.yaml +10 -2
  73. package/components/pagination/pagination.a2ui.json +7 -1
  74. package/components/pagination/pagination.d.ts +6 -1
  75. package/components/pagination/pagination.yaml +4 -0
  76. package/components/stream/stream.a2ui.json +7 -1
  77. package/components/stream/stream.d.ts +6 -1
  78. package/components/stream/stream.yaml +4 -0
  79. package/components/swatch/class.js +362 -15
  80. package/components/swatch/swatch.a2ui.json +68 -1
  81. package/components/swatch/swatch.css +150 -0
  82. package/components/swatch/swatch.d.ts +43 -0
  83. package/components/swatch/swatch.yaml +67 -1
  84. package/components/swiper/swiper.a2ui.json +18 -2
  85. package/components/swiper/swiper.d.ts +14 -2
  86. package/components/swiper/swiper.yaml +11 -0
  87. package/components/table/table.a2ui.json +80 -5
  88. package/components/table/table.d.ts +58 -5
  89. package/components/table/table.yaml +54 -2
  90. package/components/tabs/tabs.a2ui.json +7 -1
  91. package/components/tabs/tabs.d.ts +6 -1
  92. package/components/tabs/tabs.yaml +4 -0
  93. package/components/tag/tag.a2ui.json +11 -1
  94. package/components/tag/tag.d.ts +8 -1
  95. package/components/tag/tag.yaml +7 -0
  96. package/components/timeline/timeline.a2ui.json +3 -7
  97. package/components/timeline/timeline.d.ts +2 -4
  98. package/components/timeline/timeline.yaml +3 -6
  99. package/components/toggle-group/toggle-group.a2ui.json +7 -1
  100. package/components/toggle-group/toggle-group.d.ts +6 -1
  101. package/components/toggle-group/toggle-group.yaml +4 -0
  102. package/components/toggle-scheme/toggle-scheme.a2ui.json +11 -1
  103. package/components/toggle-scheme/toggle-scheme.d.ts +8 -1
  104. package/components/toggle-scheme/toggle-scheme.yaml +7 -0
  105. package/components/tree/tree.a2ui.json +15 -1
  106. package/components/tree/tree.d.ts +10 -1
  107. package/components/tree/tree.yaml +10 -0
  108. package/core/data-stream.d.ts +56 -0
  109. package/core/element.d.ts +10 -0
  110. package/core/index.d.ts +6 -0
  111. package/index.d.ts +9 -2
  112. package/package.json +2 -2
@@ -12,6 +12,16 @@ export interface OtpInputEventDetail {
12
12
  }
13
13
  export type OtpInputEvent = CustomEvent<OtpInputEventDetail>;
14
14
 
15
+ /**
16
+ * Detail payload for the `complete` event — fired exactly once when the
17
+ * user fills the last digit slot. `value` is the full combined string at
18
+ * the moment of completion.
19
+ */
20
+ export interface OtpInputCompleteEventDetail {
21
+ value: string;
22
+ }
23
+ export type OtpInputCompleteEvent = CustomEvent<OtpInputCompleteEventDetail>;
24
+
15
25
  export class UIOtpInput extends UIFormElement {
16
26
  /** Number of digit slots. */
17
27
  length: number;
@@ -22,4 +32,5 @@ export class UIOtpInput extends UIFormElement {
22
32
  options?: boolean | AddEventListenerOptions,
23
33
  ): void;
24
34
  addEventListener(type: 'input', listener: (ev: OtpInputEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
35
+ addEventListener(type: 'complete', listener: (ev: OtpInputCompleteEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
25
36
  }
@@ -25,9 +25,17 @@ props:
25
25
  default: ''
26
26
  events:
27
27
  complete:
28
- description: Fired when all digits are filled. Detail contains { code }.
28
+ description: Fired exactly once when the user fills the last digit slot. detail.value is the combined string.
29
+ detail:
30
+ value:
31
+ type: string
32
+ description: Combined digits (e.g. "123456").
29
33
  input:
30
- description: Fired on every digit change
34
+ description: Fired on every digit change. detail.value is the current combined string.
35
+ detail:
36
+ value:
37
+ type: string
38
+ description: Combined digits at the moment of the change.
31
39
  slots:
32
40
  field:
33
41
  description: Container for the digit input boxes and optional separator
@@ -47,7 +47,13 @@
47
47
  "composes": [],
48
48
  "events": {
49
49
  "page-change": {
50
- "description": "Fired when a page button is clicked. detail contains { page }."
50
+ "description": "Fired when a page button is clicked. detail contains { page }.",
51
+ "detail": {
52
+ "page": {
53
+ "description": "New page number.",
54
+ "type": "number"
55
+ }
56
+ }
51
57
  }
52
58
  },
53
59
  "examples": [
@@ -12,7 +12,12 @@
12
12
 
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
- export type PaginationPageChangeEvent = CustomEvent<unknown>;
15
+ export interface PaginationPageChangeEventDetail {
16
+ /** New page number. */
17
+ page: number;
18
+ }
19
+
20
+ export type PaginationPageChangeEvent = CustomEvent<PaginationPageChangeEventDetail>;
16
21
 
17
22
  export class UIPagination extends UIElement {
18
23
  /** Current active page number. */
@@ -27,6 +27,10 @@ props:
27
27
  events:
28
28
  page-change:
29
29
  description: Fired when a page button is clicked. detail contains { page }.
30
+ detail:
31
+ page:
32
+ type: number
33
+ description: New page number.
30
34
  slots: {}
31
35
  states:
32
36
  - name: idle
@@ -45,7 +45,13 @@
45
45
  "description": "Fired when stream completes naturally"
46
46
  },
47
47
  "stream-error": {
48
- "description": "Fired on stream error, detail: { error }"
48
+ "description": "Fired on stream error, detail: { error }",
49
+ "detail": {
50
+ "error": {
51
+ "description": "Error object or message.",
52
+ "type": "object"
53
+ }
54
+ }
49
55
  },
50
56
  "stream-start": {
51
57
  "description": "Fired when streaming begins"
@@ -13,7 +13,12 @@
13
13
  import { UIElement } from '../../core/element.js';
14
14
 
15
15
  export type StreamEndEvent = CustomEvent<unknown>;
16
- export type StreamErrorEvent = CustomEvent<unknown>;
16
+ export interface StreamErrorEventDetail {
17
+ /** Error object or message. */
18
+ error: Record<string, unknown>;
19
+ }
20
+
21
+ export type StreamErrorEvent = CustomEvent<StreamErrorEventDetail>;
17
22
  export type StreamStartEvent = CustomEvent<unknown>;
18
23
 
19
24
  export class UIStream extends UIElement {
@@ -25,6 +25,10 @@ events:
25
25
  description: Fired when stream completes naturally
26
26
  stream-error:
27
27
  description: "Fired on stream error, detail: { error }"
28
+ detail:
29
+ error:
30
+ type: object
31
+ description: Error object or message.
28
32
  stream-start:
29
33
  description: Fired when streaming begins
30
34
  slots:
@@ -19,59 +19,201 @@
19
19
  * theme-preview pill. Replaces docs-only `<span data-swatch>` usage and
20
20
  * the per-shape CSS that used to live inside chart-legend-ui.
21
21
  *
22
+ * v0.4.9 extends the primitive with the design-token-tile use case
23
+ * (FEEDBACK-04 #2): badge marker, second-line detail, copy-to-clipboard
24
+ * button, keyboard-selectable + reflected `[selected]` state.
25
+ *
22
26
  * Usage:
23
27
  * <swatch-ui shape="dot" color="var(--a-data-0)" label="Revenue"></swatch-ui>
24
28
  * <swatch-ui shape="block" size="lg" color="var(--a-neutral-50)" label="50"></swatch-ui>
25
29
  * <swatch-ui shape="line" color="#0ea5e9">Forecast</swatch-ui>
26
30
  *
31
+ * <!-- design-token tile with full feature set -->
32
+ * <swatch-ui
33
+ * shape="block" size="lg"
34
+ * color="oklch(0.53 0.18 240)"
35
+ * label="500"
36
+ * detail="oklch(0.53 0.18 240)"
37
+ * badge="apca-pass"
38
+ * copyable
39
+ * selectable
40
+ * ></swatch-ui>
41
+ *
27
42
  * Attributes:
28
- * shapeblock | dot | square | line | dashed (default square)
29
- * sizesm | md | lg (default md). lg is the 40px-tall token-scale block.
30
- * colorany CSS color (or var() ref). Sets --swatch-color internally.
31
- * label — optional text label rendered beside / below the swatch
43
+ * shape block | dot | square | line | dashed (default square)
44
+ * size sm | md | lg (default md)
45
+ * color any CSS color (or var() ref)
46
+ * label text label rendered beside / below the swatch
47
+ * detail secondary line of text (e.g. raw token value)
48
+ * badge out-of-gamut | p3-only | apca-pass | apca-fail (optional marker)
49
+ * copyable boolean — render an inline copy button
50
+ * copy-value string — value to copy (defaults to [color] when copyable)
51
+ * selectable boolean — make the swatch focusable + clickable
52
+ * selected boolean — reflected visual selected state
53
+ *
54
+ * Events:
55
+ * select dispatched when a selectable swatch is activated
56
+ * (click / Enter / Space). detail: { value, color, label }.
32
57
  *
33
58
  * Slots:
34
- * defaultrich label content; takes precedence over [label] attr
59
+ * default rich label content; takes precedence over [label] attr
60
+ *
61
+ * Stamping is light-DOM with up to five children:
62
+ * <span data-tile></span> colored shape
63
+ * <span data-badge></span> gamut / contrast marker (optional)
64
+ * <span data-label></span> label text
65
+ * <span data-detail></span> secondary line (optional)
66
+ * <button data-copy></button> copy-to-clipboard (optional)
35
67
  *
36
- * Stamping is light-DOM with two children:
37
- * <span data-tile></span> — the colored shape
38
- * <span data-label></span> — the label text (omitted when no label/slot)
68
+ * Accessibility:
69
+ * - When [selectable], host is role="button" + tabindex="0";
70
+ * Enter / Space activate.
71
+ * - When [selected], aria-pressed="true" reflects the state.
72
+ * - The badge has aria-label (mapped per variant) so screen readers
73
+ * surface "out of gamut", "P3-only", etc.
39
74
  */
40
75
 
41
76
  import { UIElement } from '../../core/element.js';
42
77
 
43
78
  const SHAPES = new Set(['block', 'dot', 'square', 'line', 'dashed']);
44
79
  const SIZES = new Set(['sm', 'md', 'lg']);
80
+ const BADGE_VARIANTS = new Set(['out-of-gamut', 'p3-only', 'apca-pass', 'apca-fail']);
81
+
82
+ const BADGE_SYMBOLS = {
83
+ 'out-of-gamut': '△',
84
+ 'p3-only': '✦',
85
+ 'apca-pass': '✓',
86
+ 'apca-fail': '✗',
87
+ };
88
+
89
+ const BADGE_LABELS = {
90
+ 'out-of-gamut': 'Outside sRGB gamut',
91
+ 'p3-only': 'Display-P3 only',
92
+ 'apca-pass': 'Contrast passes APCA',
93
+ 'apca-fail': 'Contrast fails APCA',
94
+ };
95
+
96
+ /**
97
+ * Parse the [badge] attribute into a normalized array. Supports:
98
+ * badge="apca-pass" → ['apca-pass']
99
+ * badge="out-of-gamut, apca-fail" → ['out-of-gamut', 'apca-fail']
100
+ * badge="out-of-gamut apca-fail" → ['out-of-gamut', 'apca-fail']
101
+ * badge="" → []
102
+ * Drops unknown variants silently (consumer-typed-by-attr defense).
103
+ */
104
+ function parseBadges(value) {
105
+ if (!value) return [];
106
+ return value
107
+ .split(/[\s,]+/)
108
+ .map((s) => s.trim())
109
+ .filter((s) => s && BADGE_VARIANTS.has(s));
110
+ }
111
+
112
+ /**
113
+ * Compute perceived lightness (OKLCH L, 0–1) of a CSS color string. Returns
114
+ * null when the color isn't a hex / oklch / rgb / named CSS color that we
115
+ * can parse. Used to pick a light vs dark label color for auto-contrast.
116
+ *
117
+ * Strategy: render the color into a temporary canvas, read the sRGB pixel,
118
+ * convert to linear-light sRGB → OKLab L. This is ~3x cheaper than parsing
119
+ * the CSS color string ourselves and handles every CSS color form the
120
+ * browser supports (oklch, hsl, named, hex, currentColor via fallback).
121
+ */
122
+ let _lumProbeCtx = null;
123
+ function colorLuminance(cssColor) {
124
+ if (!cssColor || typeof cssColor !== 'string') return null;
125
+ if (typeof document === 'undefined') return null;
126
+ try {
127
+ if (!_lumProbeCtx) {
128
+ const canvas = document.createElement('canvas');
129
+ canvas.width = 1;
130
+ canvas.height = 1;
131
+ _lumProbeCtx = canvas.getContext('2d');
132
+ }
133
+ _lumProbeCtx.fillStyle = '#000';
134
+ _lumProbeCtx.clearRect(0, 0, 1, 1);
135
+ _lumProbeCtx.fillStyle = cssColor;
136
+ _lumProbeCtx.fillRect(0, 0, 1, 1);
137
+ const [r, g, b] = _lumProbeCtx.getImageData(0, 0, 1, 1).data;
138
+ if (r === 0 && g === 0 && b === 0 && cssColor !== '#000' && cssColor !== 'black' && cssColor !== '#000000') {
139
+ // Canvas rejected the color string (e.g. var() that doesn't resolve in this context).
140
+ return null;
141
+ }
142
+ // sRGB → linear-light, then OKLab L (Björn Ottosson's approximation).
143
+ const lin = (c) => {
144
+ const x = c / 255;
145
+ return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
146
+ };
147
+ const lr = lin(r), lg = lin(g), lb = lin(b);
148
+ const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
149
+ const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
150
+ const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
151
+ return 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
45
156
 
46
157
  export class UISwatch extends UIElement {
47
158
  static properties = {
48
- shape: { type: String, default: 'square', reflect: true },
49
- size: { type: String, default: 'md', reflect: true },
50
- color: { type: String, default: '', reflect: true },
51
- label: { type: String, default: '', reflect: true },
159
+ shape: { type: String, default: 'square', reflect: true },
160
+ size: { type: String, default: 'md', reflect: true },
161
+ color: { type: String, default: '', reflect: true },
162
+ label: { type: String, default: '', reflect: true },
163
+ detail: { type: String, default: '', reflect: true },
164
+ badge: { type: String, default: '', reflect: true },
165
+ copyable: { type: Boolean, default: false, reflect: true },
166
+ copyValue: { type: String, default: '' }, // not reflected — long color strings as attrs read poorly
167
+ selectable: { type: Boolean, default: false, reflect: true },
168
+ selected: { type: Boolean, default: false, reflect: true },
169
+ autoContrast: { type: Boolean, default: false, reflect: true },
52
170
  };
53
171
 
54
172
  static template = () => null;
55
173
 
56
174
  #tileEl = null;
57
175
  #labelEl = null;
176
+ #detailEl = null;
177
+ #badgeEl = null;
178
+ #copyEl = null;
58
179
  #stamped = false;
180
+ #lastColorForContrast = null;
181
+
182
+ // Bound handlers — held so we can remove them in disconnected().
183
+ #onHostClick = null;
184
+ #onHostKey = null;
185
+ #onCopyClick = null;
186
+ #copyResetTimer = null;
59
187
 
60
188
  connected() {
61
189
  this.#stamp();
190
+ this.#wireInteraction();
62
191
  }
63
192
 
64
193
  render() {
65
194
  this.#stamp();
66
- this.#sync();
195
+ this.#syncCore();
196
+ this.#syncBadge();
197
+ this.#syncDetail();
198
+ this.#syncCopy();
199
+ this.#syncSelectable();
200
+ this.#syncAutoContrast();
67
201
  }
68
202
 
203
+ // ── Stamping ──────────────────────────────────────────────────────
204
+
69
205
  #stamp() {
70
206
  if (this.#stamped) return;
71
207
  // Capture pre-existing default-slot content so consumer-authored
72
208
  // children (e.g. <swatch-ui>Forecast</swatch-ui>) survive stamping.
73
209
  const slotted = Array.from(this.childNodes).filter(n =>
74
- !(n.nodeType === 1 && (n.dataset?.tile !== undefined || n.dataset?.label !== undefined))
210
+ !(n.nodeType === 1 && n.dataset && (
211
+ n.dataset.tile !== undefined ||
212
+ n.dataset.label !== undefined ||
213
+ n.dataset.detail !== undefined ||
214
+ n.dataset.badge !== undefined ||
215
+ n.dataset.copy !== undefined
216
+ ))
75
217
  );
76
218
  this.innerHTML = '';
77
219
 
@@ -80,6 +222,13 @@ export class UISwatch extends UIElement {
80
222
  this.#tileEl.setAttribute('aria-hidden', 'true');
81
223
  this.appendChild(this.#tileEl);
82
224
 
225
+ // Badge container — holds one or more <span data-badge-variant="..."> children.
226
+ // Multi-badge support added in v0.4.9 §92 (FEEDBACK-04 follow-up).
227
+ this.#badgeEl = document.createElement('span');
228
+ this.#badgeEl.setAttribute('data-badge', '');
229
+ this.#badgeEl.setAttribute('hidden', '');
230
+ this.appendChild(this.#badgeEl);
231
+
83
232
  this.#labelEl = document.createElement('span');
84
233
  this.#labelEl.setAttribute('data-label', '');
85
234
  if (slotted.length) {
@@ -87,10 +236,25 @@ export class UISwatch extends UIElement {
87
236
  }
88
237
  this.appendChild(this.#labelEl);
89
238
 
239
+ this.#detailEl = document.createElement('span');
240
+ this.#detailEl.setAttribute('data-detail', '');
241
+ this.#detailEl.setAttribute('hidden', '');
242
+ this.appendChild(this.#detailEl);
243
+
244
+ this.#copyEl = document.createElement('button');
245
+ this.#copyEl.setAttribute('data-copy', '');
246
+ this.#copyEl.setAttribute('type', 'button');
247
+ this.#copyEl.setAttribute('aria-label', 'Copy value');
248
+ this.#copyEl.setAttribute('hidden', '');
249
+ this.#copyEl.textContent = '⧉';
250
+ this.appendChild(this.#copyEl);
251
+
90
252
  this.#stamped = true;
91
253
  }
92
254
 
93
- #sync() {
255
+ // ── Sync ──────────────────────────────────────────────────────────
256
+
257
+ #syncCore() {
94
258
  if (!this.#tileEl || !this.#labelEl) return;
95
259
 
96
260
  // Normalize enum values; fall back silently when a consumer sets a typo.
@@ -123,9 +287,192 @@ export class UISwatch extends UIElement {
123
287
  this.#labelEl.toggleAttribute('hidden', !hasLabel);
124
288
  }
125
289
 
290
+ #syncBadge() {
291
+ if (!this.#badgeEl) return;
292
+ const badges = parseBadges(this.badge);
293
+ if (badges.length === 0) {
294
+ this.#badgeEl.setAttribute('hidden', '');
295
+ this.#badgeEl.replaceChildren();
296
+ return;
297
+ }
298
+ this.#badgeEl.removeAttribute('hidden');
299
+ // Diff against existing children — replace only if the variant set changed.
300
+ const declared = Array.from(this.#badgeEl.children).map((c) => c.dataset.badgeVariant);
301
+ const same = declared.length === badges.length && declared.every((v, i) => v === badges[i]);
302
+ if (same) return;
303
+ const frag = document.createDocumentFragment();
304
+ for (const variant of badges) {
305
+ const pip = document.createElement('span');
306
+ pip.setAttribute('data-badge-variant', variant);
307
+ pip.setAttribute('role', 'img');
308
+ pip.setAttribute('aria-label', BADGE_LABELS[variant]);
309
+ pip.textContent = BADGE_SYMBOLS[variant];
310
+ frag.appendChild(pip);
311
+ }
312
+ this.#badgeEl.replaceChildren(frag);
313
+ }
314
+
315
+ /**
316
+ * Auto-contrast — switch the label color between light/dark when the
317
+ * resolved swatch color crosses an OKLab L threshold. Uses a 1px canvas
318
+ * probe to handle any CSS color form (oklch / hex / hsl / named / var()).
319
+ * Pre-resolves currentColor by way of computed style so consumers can
320
+ * pass var() refs and get correct math.
321
+ */
322
+ #syncAutoContrast() {
323
+ if (!this.#labelEl) return;
324
+ if (!this.autoContrast) {
325
+ this.#labelEl.removeAttribute('data-on-light');
326
+ this.#labelEl.removeAttribute('data-on-dark');
327
+ this.#lastColorForContrast = null;
328
+ return;
329
+ }
330
+ // Resolve the actual rendered color. For var(...) refs the inline
331
+ // [color] attr is what was set; we read from computed style so that
332
+ // CSS-resolved tokens (e.g. var(--a-accent)) get their final value.
333
+ const probe = this.color || getComputedStyle(this).getPropertyValue('--swatch-color').trim();
334
+ if (probe === this.#lastColorForContrast) return;
335
+ this.#lastColorForContrast = probe;
336
+ const L = colorLuminance(probe);
337
+ if (L == null) {
338
+ this.#labelEl.removeAttribute('data-on-light');
339
+ this.#labelEl.removeAttribute('data-on-dark');
340
+ return;
341
+ }
342
+ // Threshold ~0.62 OKLab L matches WCAG-aligned light/dark text choice
343
+ // for sRGB. Adjust by --swatch-auto-contrast-threshold if needed.
344
+ const onDark = L < 0.62;
345
+ this.#labelEl.toggleAttribute('data-on-dark', onDark);
346
+ this.#labelEl.toggleAttribute('data-on-light', !onDark);
347
+ }
348
+
349
+ #syncDetail() {
350
+ if (!this.#detailEl) return;
351
+ const text = (this.detail || '').trim();
352
+ if (!text) {
353
+ this.#detailEl.setAttribute('hidden', '');
354
+ this.#detailEl.textContent = '';
355
+ return;
356
+ }
357
+ this.#detailEl.removeAttribute('hidden');
358
+ if (this.#detailEl.textContent !== text) this.#detailEl.textContent = text;
359
+ }
360
+
361
+ #syncCopy() {
362
+ if (!this.#copyEl) return;
363
+ if (!this.copyable) {
364
+ this.#copyEl.setAttribute('hidden', '');
365
+ return;
366
+ }
367
+ this.#copyEl.removeAttribute('hidden');
368
+ // aria-label reflects what will be copied so screen readers
369
+ // announce the target value.
370
+ const value = this.#copyTargetValue();
371
+ if (value) this.#copyEl.setAttribute('aria-label', `Copy ${value}`);
372
+ else this.#copyEl.setAttribute('aria-label', 'Copy value');
373
+ }
374
+
375
+ #syncSelectable() {
376
+ if (this.selectable) {
377
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'button');
378
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
379
+ this.setAttribute('aria-pressed', this.selected ? 'true' : 'false');
380
+ } else {
381
+ // Only remove ARIA we set; respect consumer-set roles.
382
+ if (this.getAttribute('role') === 'button') this.removeAttribute('role');
383
+ if (this.getAttribute('tabindex') === '0') this.removeAttribute('tabindex');
384
+ this.removeAttribute('aria-pressed');
385
+ }
386
+ }
387
+
388
+ // ── Interaction wiring ────────────────────────────────────────────
389
+
390
+ #wireInteraction() {
391
+ this.#onHostClick = (e) => {
392
+ if (!this.selectable) return;
393
+ // Ignore clicks that originated in the copy button — that's a
394
+ // separate affordance and should not also toggle selection.
395
+ const path = e.composedPath ? e.composedPath() : [];
396
+ if (path.includes(this.#copyEl)) return;
397
+ this.#activate();
398
+ };
399
+ this.#onHostKey = (e) => {
400
+ if (!this.selectable) return;
401
+ if (e.key !== 'Enter' && e.key !== ' ') return;
402
+ // Don't steal Space when focus is in the copy button.
403
+ if (e.target === this.#copyEl) return;
404
+ e.preventDefault();
405
+ this.#activate();
406
+ };
407
+ this.addEventListener('click', this.#onHostClick);
408
+ this.addEventListener('keydown', this.#onHostKey);
409
+
410
+ this.#onCopyClick = (e) => {
411
+ e.stopPropagation();
412
+ if (!this.copyable) return;
413
+ const value = this.#copyTargetValue();
414
+ if (!value) return;
415
+ this.#doCopy(value);
416
+ };
417
+ this.#copyEl.addEventListener('click', this.#onCopyClick);
418
+ }
419
+
420
+ #activate() {
421
+ this.dispatchEvent(new CustomEvent('select', {
422
+ bubbles: true,
423
+ detail: {
424
+ value: this.color || this.label,
425
+ color: this.color,
426
+ label: this.label,
427
+ },
428
+ }));
429
+ }
430
+
431
+ #copyTargetValue() {
432
+ if (this.copyValue) return this.copyValue;
433
+ if (this.color) return this.color;
434
+ return '';
435
+ }
436
+
437
+ async #doCopy(value) {
438
+ let ok = false;
439
+ try {
440
+ if (navigator?.clipboard?.writeText) {
441
+ await navigator.clipboard.writeText(value);
442
+ ok = true;
443
+ }
444
+ } catch { /* ignore — feedback below */ }
445
+ this.#flashCopyResult(ok);
446
+ }
447
+
448
+ #flashCopyResult(ok) {
449
+ if (!this.#copyEl) return;
450
+ if (this.#copyResetTimer != null) clearTimeout(this.#copyResetTimer);
451
+ this.#copyEl.setAttribute('data-copy-state', ok ? 'ok' : 'fail');
452
+ this.#copyEl.textContent = ok ? '✓' : '⚠';
453
+ this.#copyResetTimer = setTimeout(() => {
454
+ if (!this.#copyEl) return;
455
+ this.#copyEl.removeAttribute('data-copy-state');
456
+ this.#copyEl.textContent = '⧉';
457
+ this.#copyResetTimer = null;
458
+ }, 1200);
459
+ }
460
+
461
+ // ── Teardown ──────────────────────────────────────────────────────
462
+
126
463
  disconnected() {
464
+ if (this.#copyResetTimer != null) {
465
+ clearTimeout(this.#copyResetTimer);
466
+ this.#copyResetTimer = null;
467
+ }
468
+ if (this.#onHostClick) this.removeEventListener('click', this.#onHostClick);
469
+ if (this.#onHostKey) this.removeEventListener('keydown', this.#onHostKey);
470
+ if (this.#copyEl && this.#onCopyClick) this.#copyEl.removeEventListener('click', this.#onCopyClick);
127
471
  this.#tileEl = null;
128
472
  this.#labelEl = null;
473
+ this.#detailEl = null;
474
+ this.#badgeEl = null;
475
+ this.#copyEl = null;
129
476
  this.#stamped = false;
130
477
  }
131
478
  }