@adia-ai/web-components 0.4.7 → 0.4.9

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 (236) hide show
  1. package/README.md +39 -0
  2. package/USAGE.md +255 -2
  3. package/components/accordion/accordion.a2ui.json +3 -0
  4. package/components/accordion/accordion.d.ts +12 -2
  5. package/components/accordion/accordion.yaml +4 -0
  6. package/components/action-list/action-list.a2ui.json +18 -1
  7. package/components/action-list/action-list.d.ts +21 -2
  8. package/components/action-list/action-list.yaml +14 -0
  9. package/components/agent-artifact/agent-artifact.a2ui.json +11 -1
  10. package/components/agent-artifact/agent-artifact.d.ts +17 -2
  11. package/components/agent-artifact/agent-artifact.yaml +9 -0
  12. package/components/agent-feedback-bar/agent-feedback-bar.a2ui.json +10 -1
  13. package/components/agent-feedback-bar/agent-feedback-bar.d.ts +19 -2
  14. package/components/agent-feedback-bar/agent-feedback-bar.yaml +8 -0
  15. package/components/agent-questions/agent-questions.a2ui.json +14 -1
  16. package/components/agent-questions/agent-questions.d.ts +19 -2
  17. package/components/agent-questions/agent-questions.yaml +11 -0
  18. package/components/agent-reasoning/agent-reasoning.a2ui.json +29 -3
  19. package/components/agent-reasoning/agent-reasoning.d.ts +33 -2
  20. package/components/agent-reasoning/agent-reasoning.yaml +20 -0
  21. package/components/agent-suggestions/agent-suggestions.a2ui.json +18 -1
  22. package/components/agent-suggestions/agent-suggestions.d.ts +21 -2
  23. package/components/agent-suggestions/agent-suggestions.yaml +14 -0
  24. package/components/agent-trace/agent-trace.a2ui.json +8 -1
  25. package/components/agent-trace/agent-trace.d.ts +17 -2
  26. package/components/agent-trace/agent-trace.yaml +4 -0
  27. package/components/alert/alert.a2ui.json +1 -0
  28. package/components/alert/alert.d.ts +12 -2
  29. package/components/aside/aside.a2ui.json +1 -0
  30. package/components/avatar/avatar.a2ui.json +3 -0
  31. package/components/avatar/avatar.d.ts +3 -2
  32. package/components/avatar/avatar.yaml +4 -0
  33. package/components/badge/badge.a2ui.json +3 -0
  34. package/components/badge/badge.d.ts +3 -2
  35. package/components/badge/badge.yaml +4 -0
  36. package/components/block/block.a2ui.json +1 -0
  37. package/components/block/block.d.ts +3 -2
  38. package/components/breadcrumb/breadcrumb.a2ui.json +5 -0
  39. package/components/breadcrumb/breadcrumb.d.ts +3 -2
  40. package/components/breadcrumb/breadcrumb.yaml +6 -0
  41. package/components/button/button.a2ui.json +3 -0
  42. package/components/button/button.d.ts +12 -2
  43. package/components/button/button.yaml +5 -0
  44. package/components/calendar-picker/calendar-picker.a2ui.json +1 -0
  45. package/components/canvas/canvas.a2ui.json +1 -0
  46. package/components/canvas/canvas.d.ts +18 -2
  47. package/components/canvas/canvas.yaml +12 -0
  48. package/components/card/card.a2ui.json +1 -0
  49. package/components/card/card.d.ts +12 -2
  50. package/components/chart/chart.a2ui.json +4 -0
  51. package/components/chart/chart.d.ts +18 -2
  52. package/components/chart/chart.yaml +5 -0
  53. package/components/chart-legend/chart-legend.a2ui.json +19 -1
  54. package/components/chart-legend/chart-legend.d.ts +21 -2
  55. package/components/chart-legend/chart-legend.yaml +15 -0
  56. package/components/chat-thread/chat-thread.a2ui.json +12 -1
  57. package/components/chat-thread/chat-thread.d.ts +19 -2
  58. package/components/chat-thread/chat-thread.yaml +7 -0
  59. package/components/check/check.a2ui.json +1 -0
  60. package/components/code/code.a2ui.json +37 -7
  61. package/components/code/code.d.ts +30 -0
  62. package/components/code/code.yaml +29 -6
  63. package/components/col/col.a2ui.json +1 -0
  64. package/components/col/col.d.ts +3 -2
  65. package/components/color-picker/class.js +59 -1
  66. package/components/color-picker/color-picker.a2ui.json +37 -0
  67. package/components/color-picker/color-picker.d.ts +70 -8
  68. package/components/color-picker/color-picker.yaml +53 -0
  69. package/components/command/command.a2ui.json +12 -1
  70. package/components/command/command.d.ts +21 -2
  71. package/components/command/command.yaml +7 -0
  72. package/components/demo-toggle/demo-toggle.a2ui.json +8 -1
  73. package/components/demo-toggle/demo-toggle.d.ts +17 -2
  74. package/components/demo-toggle/demo-toggle.yaml +4 -0
  75. package/components/description-list/description-list.a2ui.json +1 -0
  76. package/components/description-list/description-list.d.ts +3 -2
  77. package/components/divider/divider.a2ui.json +1 -0
  78. package/components/divider/divider.d.ts +3 -2
  79. package/components/drawer/drawer.a2ui.json +1 -0
  80. package/components/drawer/drawer.d.ts +12 -2
  81. package/components/embed/embed.a2ui.json +1 -0
  82. package/components/embed/embed.d.ts +3 -2
  83. package/components/empty-state/empty-state.a2ui.json +3 -0
  84. package/components/empty-state/empty-state.d.ts +3 -2
  85. package/components/empty-state/empty-state.yaml +4 -0
  86. package/components/feed/feed.a2ui.json +9 -1
  87. package/components/feed/feed.d.ts +12 -2
  88. package/components/feed/feed.yaml +8 -1
  89. package/components/field/field.a2ui.json +1 -0
  90. package/components/field/field.d.ts +3 -2
  91. package/components/fields/fields.a2ui.json +1 -0
  92. package/components/fields/fields.d.ts +3 -2
  93. package/components/footer/footer.a2ui.json +1 -0
  94. package/components/grid/grid.a2ui.json +1 -0
  95. package/components/grid/grid.d.ts +3 -2
  96. package/components/header/header.a2ui.json +1 -0
  97. package/components/heatmap/heatmap.a2ui.json +12 -2
  98. package/components/heatmap/heatmap.d.ts +20 -2
  99. package/components/heatmap/heatmap.yaml +17 -2
  100. package/components/icon/icon.a2ui.json +1 -0
  101. package/components/icon/icon.d.ts +3 -2
  102. package/components/image/image.a2ui.json +3 -0
  103. package/components/image/image.d.ts +3 -2
  104. package/components/image/image.yaml +4 -0
  105. package/components/index.js +8 -0
  106. package/components/input/input.a2ui.json +4 -0
  107. package/components/input/input.yaml +6 -0
  108. package/components/inspector/inspector.a2ui.json +5 -0
  109. package/components/inspector/inspector.d.ts +3 -2
  110. package/components/inspector/inspector.yaml +6 -0
  111. package/components/kbd/kbd.a2ui.json +1 -0
  112. package/components/kbd/kbd.d.ts +3 -2
  113. package/components/link/link.a2ui.json +12 -1
  114. package/components/link/link.d.ts +19 -2
  115. package/components/link/link.yaml +7 -0
  116. package/components/list/list.a2ui.json +14 -1
  117. package/components/list/list.d.ts +19 -2
  118. package/components/list/list.yaml +11 -0
  119. package/components/menu/menu.a2ui.json +14 -1
  120. package/components/menu/menu.d.ts +19 -2
  121. package/components/menu/menu.yaml +11 -0
  122. package/components/modal/modal.a2ui.json +1 -0
  123. package/components/modal/modal.d.ts +12 -2
  124. package/components/nav/nav.a2ui.json +16 -1
  125. package/components/nav/nav.d.ts +21 -2
  126. package/components/nav/nav.yaml +10 -0
  127. package/components/nav-group/nav-group.a2ui.json +12 -1
  128. package/components/nav-group/nav-group.d.ts +19 -2
  129. package/components/nav-group/nav-group.yaml +7 -0
  130. package/components/nav-item/nav-item.a2ui.json +16 -1
  131. package/components/nav-item/nav-item.d.ts +21 -2
  132. package/components/nav-item/nav-item.yaml +10 -0
  133. package/components/noodles/noodles.a2ui.json +47 -2
  134. package/components/noodles/noodles.d.ts +42 -2
  135. package/components/noodles/noodles.yaml +32 -0
  136. package/components/option-card/option-card.a2ui.json +3 -0
  137. package/components/option-card/option-card.yaml +4 -0
  138. package/components/otp-input/otp-input.a2ui.json +15 -2
  139. package/components/otp-input/otp-input.d.ts +11 -0
  140. package/components/otp-input/otp-input.yaml +10 -2
  141. package/components/page/page.a2ui.json +1 -0
  142. package/components/page/page.d.ts +3 -2
  143. package/components/pagination/pagination.a2ui.json +8 -1
  144. package/components/pagination/pagination.d.ts +17 -2
  145. package/components/pagination/pagination.yaml +4 -0
  146. package/components/pane/pane.a2ui.json +8 -1
  147. package/components/pane/pane.d.ts +12 -2
  148. package/components/pane/pane.yaml +7 -1
  149. package/components/pipeline-status/pipeline-status.a2ui.json +1 -0
  150. package/components/pipeline-status/pipeline-status.d.ts +3 -2
  151. package/components/popover/popover.a2ui.json +1 -0
  152. package/components/popover/popover.d.ts +3 -2
  153. package/components/progress/progress.a2ui.json +1 -0
  154. package/components/progress/progress.d.ts +3 -2
  155. package/components/progress-row/progress-row.a2ui.json +3 -0
  156. package/components/progress-row/progress-row.d.ts +3 -2
  157. package/components/progress-row/progress-row.yaml +4 -0
  158. package/components/radio/radio.a2ui.json +1 -0
  159. package/components/range/range.a2ui.json +1 -0
  160. package/components/rating/rating.a2ui.json +1 -0
  161. package/components/richtext/richtext.a2ui.json +1 -0
  162. package/components/richtext/richtext.d.ts +3 -2
  163. package/components/row/row.a2ui.json +1 -0
  164. package/components/row/row.d.ts +12 -2
  165. package/components/search/search.a2ui.json +1 -0
  166. package/components/section/section.a2ui.json +1 -0
  167. package/components/segment/segment.a2ui.json +3 -0
  168. package/components/segment/segment.d.ts +3 -2
  169. package/components/segment/segment.yaml +4 -0
  170. package/components/segmented/segmented.a2ui.json +1 -0
  171. package/components/select/select.a2ui.json +3 -0
  172. package/components/select/select.yaml +4 -0
  173. package/components/skeleton/skeleton.a2ui.json +1 -0
  174. package/components/skeleton/skeleton.d.ts +3 -2
  175. package/components/slider/slider.a2ui.json +1 -0
  176. package/components/stack/stack.a2ui.json +1 -0
  177. package/components/stack/stack.d.ts +3 -2
  178. package/components/stat/stat.a2ui.json +1 -0
  179. package/components/step-progress/step-progress.a2ui.json +1 -0
  180. package/components/step-progress/step-progress.d.ts +3 -2
  181. package/components/stepper/stepper.a2ui.json +3 -0
  182. package/components/stepper/stepper.d.ts +3 -2
  183. package/components/stepper/stepper.yaml +4 -0
  184. package/components/stream/stream.a2ui.json +8 -1
  185. package/components/stream/stream.d.ts +21 -2
  186. package/components/stream/stream.yaml +4 -0
  187. package/components/swatch/class.js +362 -15
  188. package/components/swatch/swatch.a2ui.json +69 -1
  189. package/components/swatch/swatch.css +150 -0
  190. package/components/swatch/swatch.d.ts +46 -2
  191. package/components/swatch/swatch.yaml +67 -1
  192. package/components/swiper/swiper.a2ui.json +21 -2
  193. package/components/swiper/swiper.d.ts +28 -2
  194. package/components/swiper/swiper.yaml +15 -0
  195. package/components/switch/switch.a2ui.json +1 -0
  196. package/components/table/table.a2ui.json +87 -5
  197. package/components/table/table.d.ts +73 -2
  198. package/components/table/table.yaml +62 -2
  199. package/components/table-toolbar/table-toolbar.a2ui.json +12 -0
  200. package/components/table-toolbar/table-toolbar.d.ts +18 -2
  201. package/components/table-toolbar/table-toolbar.yaml +13 -0
  202. package/components/tabs/tabs.a2ui.json +10 -1
  203. package/components/tabs/tabs.d.ts +17 -2
  204. package/components/tabs/tabs.yaml +8 -0
  205. package/components/tag/tag.a2ui.json +12 -1
  206. package/components/tag/tag.d.ts +19 -2
  207. package/components/tag/tag.yaml +7 -0
  208. package/components/text/text.a2ui.json +1 -0
  209. package/components/text/text.d.ts +3 -2
  210. package/components/textarea/textarea.a2ui.json +1 -0
  211. package/components/timeline/timeline.a2ui.json +14 -1
  212. package/components/timeline/timeline.d.ts +17 -2
  213. package/components/timeline/timeline.yaml +11 -1
  214. package/components/toast/toast.a2ui.json +1 -0
  215. package/components/toast/toast.d.ts +12 -2
  216. package/components/toggle-group/toggle-group.a2ui.json +8 -1
  217. package/components/toggle-group/toggle-group.d.ts +17 -2
  218. package/components/toggle-group/toggle-group.yaml +4 -0
  219. package/components/toggle-scheme/toggle-scheme.a2ui.json +14 -1
  220. package/components/toggle-scheme/toggle-scheme.d.ts +19 -2
  221. package/components/toggle-scheme/toggle-scheme.yaml +11 -0
  222. package/components/toolbar/toolbar.a2ui.json +3 -0
  223. package/components/toolbar/toolbar.d.ts +3 -2
  224. package/components/toolbar/toolbar.yaml +4 -0
  225. package/components/tooltip/tooltip.a2ui.json +1 -0
  226. package/components/tooltip/tooltip.d.ts +3 -2
  227. package/components/tree/tree.a2ui.json +18 -1
  228. package/components/tree/tree.d.ts +21 -2
  229. package/components/tree/tree.yaml +14 -0
  230. package/components/upload/upload.a2ui.json +1 -0
  231. package/core/icons-phosphor.js +93 -0
  232. package/core/icons.js +92 -90
  233. package/core/index.js +5 -0
  234. package/index.d.ts +87 -79
  235. package/index.js +7 -0
  236. package/package.json +3 -2
@@ -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
  }
@@ -13,6 +13,16 @@
13
13
  }
14
14
  ],
15
15
  "properties": {
16
+ "autoContrast": {
17
+ "description": "When set, computes the swatch color's OKLab L and switches the label /\ndetail color between light + dark so it remains legible against the\ntile background. Only applies to shape=\"block\" (where the label sits\nON the tile). Uses a 1px canvas probe to handle any CSS color form\n(oklch / hex / hsl / named / var() references).\n",
18
+ "type": "boolean",
19
+ "default": false
20
+ },
21
+ "badge": {
22
+ "description": "Optional marker(s) rendered in the upper-right of the tile. Single value\nOR comma/space-separated list of variants (v0.4.9+ multi-badge support).\nVariants: out-of-gamut (△), p3-only (✦), apca-pass (✓), apca-fail (✗).\nEach pip carries an aria-label so screen readers surface the semantic\n(\"Outside sRGB gamut\", \"Contrast passes APCA\", etc.). Unknown variants\nare silently dropped.\n",
23
+ "type": "string",
24
+ "default": ""
25
+ },
16
26
  "color": {
17
27
  "description": "Swatch color. Accepts any CSS color value or var() reference. Sets the internal --swatch-color custom property; consumers can also set --swatch-color via inline style.",
18
28
  "type": "string",
@@ -21,11 +31,36 @@
21
31
  "component": {
22
32
  "const": "Swatch"
23
33
  },
34
+ "copyable": {
35
+ "description": "When set, renders an inline copy-to-clipboard button. Defaults to copying [color]; override via [copy-value].",
36
+ "type": "boolean",
37
+ "default": false
38
+ },
39
+ "copyValue": {
40
+ "description": "Override what gets copied when [copyable] is set. Defaults to [color]. Not reflected (color strings are long; attr round-tripping reads poorly).",
41
+ "type": "string",
42
+ "default": ""
43
+ },
44
+ "detail": {
45
+ "description": "Optional secondary line of text rendered below the label. Typically the raw token value (e.g. \"oklch(0.53 0.18 240)\") when the swatch represents a design-token step.",
46
+ "type": "string",
47
+ "default": ""
48
+ },
24
49
  "label": {
25
50
  "description": "Optional label rendered next to (or below, for shape=\"block\") the swatch. Use the default slot for richer content.",
26
51
  "type": "string",
27
52
  "default": ""
28
53
  },
54
+ "selectable": {
55
+ "description": "When set, makes the swatch keyboard-focusable + clickable. Sets role=\"button\" + tabindex=\"0\". Dispatches a \"select\" event on activation (click / Enter / Space).",
56
+ "type": "boolean",
57
+ "default": false
58
+ },
59
+ "selected": {
60
+ "description": "Reflected visual selected state. Pair with [selectable] to make the swatch behave like a radio-style picker. Sets aria-pressed.",
61
+ "type": "boolean",
62
+ "default": false
63
+ },
29
64
  "shape": {
30
65
  "description": "Visual shape — block (filled tile), dot (small circle), square (small filled square), line (solid hairline), dashed (dashed hairline).",
31
66
  "type": "string",
@@ -56,7 +91,26 @@
56
91
  "x-adiaui": {
57
92
  "anti_patterns": [],
58
93
  "category": "display",
59
- "events": {},
94
+ "composes": [],
95
+ "events": {
96
+ "select": {
97
+ "description": "Fired when a selectable swatch is activated (click / Enter / Space). detail carries the swatch's value, color, and label.",
98
+ "detail": {
99
+ "color": {
100
+ "description": "The raw [color] attribute value.",
101
+ "type": "string"
102
+ },
103
+ "label": {
104
+ "description": "The [label] attribute value.",
105
+ "type": "string"
106
+ },
107
+ "value": {
108
+ "description": "The swatch's effective value — [color] when present, else [label].",
109
+ "type": "string"
110
+ }
111
+ }
112
+ }
113
+ },
60
114
  "examples": [
61
115
  {
62
116
  "description": "A single dot swatch with text label, e.g. as a chart-legend row.",
@@ -93,6 +147,11 @@
93
147
  {
94
148
  "description": "Default, ready for display.",
95
149
  "name": "idle"
150
+ },
151
+ {
152
+ "description": "Reflected when the swatch is in the selected state (pair with [selectable]).",
153
+ "attribute": "selected",
154
+ "name": "selected"
96
155
  }
97
156
  ],
98
157
  "synonyms": {
@@ -106,8 +165,17 @@
106
165
  },
107
166
  "tag": "swatch-ui",
108
167
  "tokens": {
168
+ "--swatch-badge-fg": {
169
+ "description": "Color of the badge symbol. Defaults to chrome-foreground; overridden per-badge variant."
170
+ },
109
171
  "--swatch-color": {
110
172
  "description": "Resolved color of the swatch fill / line. Wins over the [color] attr if set inline."
173
+ },
174
+ "--swatch-detail-fg": {
175
+ "description": "Color of the secondary detail line. Defaults to subtle-foreground."
176
+ },
177
+ "--swatch-select-ring": {
178
+ "description": "Color of the focus + selected ring when [selectable] is set."
111
179
  }
112
180
  },
113
181
  "traits": [],