@adia-ai/web-components 0.8.41 → 0.8.42

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 (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/MIGRATION.md +187 -0
  3. package/USAGE.md +4 -4
  4. package/components/action-list/action-item.a2ui.json +8 -3
  5. package/components/action-list/action-item.yaml +25 -7
  6. package/components/action-list/action-list.class.js +68 -6
  7. package/components/action-list/action-list.d.ts +3 -1
  8. package/components/chart/chart.a2ui.json +18 -3
  9. package/components/chart/chart.class.js +58 -16
  10. package/components/chart/chart.d.ts +9 -3
  11. package/components/chart/chart.yaml +35 -3
  12. package/components/chart-legend/chart-legend.a2ui.json +6 -1
  13. package/components/chart-legend/chart-legend.class.js +59 -6
  14. package/components/chart-legend/chart-legend.css +5 -2
  15. package/components/chart-legend/chart-legend.d.ts +3 -1
  16. package/components/chart-legend/chart-legend.yaml +15 -1
  17. package/components/color-area/color-area.a2ui.json +148 -0
  18. package/components/color-area/color-area.class.js +657 -0
  19. package/components/color-area/color-area.css +188 -0
  20. package/components/color-area/color-area.d.ts +66 -0
  21. package/components/color-area/color-area.examples.md +19 -0
  22. package/components/color-area/color-area.js +17 -0
  23. package/components/color-area/color-area.yaml +183 -0
  24. package/components/color-input/color-input.a2ui.json +5 -5
  25. package/components/color-input/color-input.class.js +9 -6
  26. package/components/color-input/color-input.css +1 -1
  27. package/components/color-input/color-input.js +2 -2
  28. package/components/color-input/color-input.yaml +14 -12
  29. package/components/color-picker/color-picker.a2ui.json +15 -12
  30. package/components/color-picker/color-picker.class.js +24 -633
  31. package/components/color-picker/color-picker.css +14 -182
  32. package/components/color-picker/color-picker.yaml +41 -41
  33. package/components/description-list/description-list.a2ui.json +1 -1
  34. package/components/description-list/description-list.css +1 -1
  35. package/components/description-list/description-list.d.ts +1 -1
  36. package/components/description-list/description-list.yaml +1 -1
  37. package/components/field/field.a2ui.json +1 -1
  38. package/components/field/field.class.js +4 -1
  39. package/components/field/field.yaml +1 -1
  40. package/components/index.js +1 -0
  41. package/components/integration-card/integration-card.examples.md +2 -1
  42. package/components/menu/menu-item.a2ui.json +8 -3
  43. package/components/menu/menu-item.yaml +24 -3
  44. package/components/menu/menu.class.js +34 -6
  45. package/components/menu/menu.d.ts +3 -1
  46. package/components/pane/pane.a2ui.json +11 -1
  47. package/components/pane/pane.class.js +32 -3
  48. package/components/pane/pane.css +15 -10
  49. package/components/pane/pane.d.ts +10 -7
  50. package/components/pane/pane.yaml +15 -1
  51. package/components/swatch/swatch.yaml +1 -1
  52. package/components/table-toolbar/table-toolbar.class.js +15 -3
  53. package/core/icons.js +5 -0
  54. package/custom-elements.json +191 -16
  55. package/dist/theme-provider.min.js +1 -1
  56. package/dist/web-components.min.css +1 -1
  57. package/dist/web-components.min.js +89 -89
  58. package/dist/web-components.sheet.js +1 -1
  59. package/index.d.ts +12 -1
  60. package/package.json +1 -1
  61. package/patterns/admin-shell/admin-shell.examples.html +2 -2
  62. package/patterns/form-system/form-system.examples.html +1 -1
  63. package/styles/components.css +1 -0
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Non-side-effect class export for `<color-area-ui>`.
3
+ *
4
+ * Importing this file gives you the class(es) without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or selective
6
+ * composition.
7
+ *
8
+ * The auto-register path stays at `@adia-ai/web-components/components/color-area`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ /**
15
+ * <color-area-ui value="#3b82f6" format="hex"></color-area-ui>
16
+ *
17
+ * OKLCH-native color picker with 2D area + H/C/L sliders (ADR-0063,
18
+ * gh#1563 — renamed from `<color-picker-ui>`, which is now a thin
19
+ * deprecated alias subclassing this class; see ../color-picker/color-picker.class.js).
20
+ *
21
+ * Layout:
22
+ * [═══════════ color area (chroma x lightness) ═══════════]
23
+ * H [────────────── slider ──────────────] 260
24
+ * C [────────────── slider ──────────────] 0.188
25
+ * L [────────────── slider ──────────────] 0.623
26
+ * [───────────────── hue track ──────────────────]
27
+ * oklch(0.62 0.188 260) [copy]
28
+ * #3b82f6 [copy]
29
+ *
30
+ * Dogfoods: slider-ui (H/C/L channels)
31
+ * Form-associated via UIFormElement + ElementInternals.
32
+ */
33
+
34
+ import { UIFormElement } from '../../core/form.js';
35
+
36
+ // ── OKLCH ↔ sRGB conversion ──────────────────────────────
37
+
38
+ const MAX_CHROMA = 0.4;
39
+
40
+ function oklchToOklab(L, C, H) {
41
+ const hRad = H * Math.PI / 180;
42
+ return { L, a: C * Math.cos(hRad), b: C * Math.sin(hRad) };
43
+ }
44
+
45
+ function oklabToLinearSrgb(L, a, b) {
46
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
47
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
48
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
49
+ const l = l_ * l_ * l_;
50
+ const m = m_ * m_ * m_;
51
+ const s = s_ * s_ * s_;
52
+ return [
53
+ +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
54
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
55
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
56
+ ];
57
+ }
58
+
59
+ function linearToSrgb(c) {
60
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
61
+ }
62
+
63
+ function srgbToLinear(c) {
64
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
65
+ }
66
+
67
+ function oklchToRgb(L, C, H) {
68
+ const { a, b } = oklchToOklab(L, C, H);
69
+ const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
70
+ return [
71
+ Math.max(0, Math.min(1, linearToSrgb(lr))),
72
+ Math.max(0, Math.min(1, linearToSrgb(lg))),
73
+ Math.max(0, Math.min(1, linearToSrgb(lb))),
74
+ ];
75
+ }
76
+
77
+ function rgbToHex(r, g, b) {
78
+ const h = c => Math.round(c * 255).toString(16).padStart(2, '0');
79
+ return `#${h(r)}${h(g)}${h(b)}`;
80
+ }
81
+
82
+ function oklchToHex(L, C, H) {
83
+ const [r, g, b] = oklchToRgb(L, C, H);
84
+ return rgbToHex(r, g, b);
85
+ }
86
+
87
+ function hexToOklch(hex) {
88
+ hex = hex.replace('#', '');
89
+ if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
90
+ const r = srgbToLinear(parseInt(hex.slice(0,2),16)/255);
91
+ const g = srgbToLinear(parseInt(hex.slice(2,4),16)/255);
92
+ const b = srgbToLinear(parseInt(hex.slice(4,6),16)/255);
93
+ const l_ = Math.cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b);
94
+ const m_ = Math.cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b);
95
+ const s_ = Math.cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b);
96
+ const L = 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_;
97
+ const a = 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_;
98
+ const bv = 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_;
99
+ const C = Math.sqrt(a*a + bv*bv);
100
+ let H = Math.atan2(bv, a) * 180 / Math.PI;
101
+ if (H < 0) H += 360;
102
+ return { L, C, H };
103
+ }
104
+
105
+ function isInGamut(r, g, b) {
106
+ const e = 0.001;
107
+ return r >= -e && r <= 1+e && g >= -e && g <= 1+e && b >= -e && b <= 1+e;
108
+ }
109
+
110
+ function gamutMapChroma(L, C, H) {
111
+ const { a, b } = oklchToOklab(L, C, H);
112
+ const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
113
+ if (isInGamut(linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb))) return C;
114
+ let lo = 0, hi = C;
115
+ for (let i = 0; i < 8; i++) {
116
+ const mid = (lo + hi) / 2;
117
+ const { a: ma, b: mb } = oklchToOklab(L, mid, H);
118
+ const [mr, mg, mbb] = oklabToLinearSrgb(L, ma, mb);
119
+ if (isInGamut(linearToSrgb(mr), linearToSrgb(mg), linearToSrgb(mbb))) lo = mid;
120
+ else hi = mid;
121
+ }
122
+ return lo;
123
+ }
124
+
125
+ // ── Component ────────────────────────────────────────────
126
+
127
+ export class UIColorArea extends UIFormElement {
128
+ // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
129
+ // consumer markup). Aggregated by installIconLoadersForRegistered()
130
+ // across all defined elements. Audited by check-required-icons.mjs
131
+ // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
132
+ static requiredIcons = ['copy', 'check', 'warning'];
133
+
134
+ // §201 (v0.5.7): once-per-element warn dedup for #parseValue malformed
135
+ // input. Matches the v0.5.5 §184 §8 button-ui icon-only safety-net pattern
136
+ // + v0.5.7 §215 select-ui parseOptions pattern. Per FEEDBACK-13 §1 +
137
+ // FEEDBACK-14 §2 (co-credited).
138
+ static #warnedBadParse = new WeakSet();
139
+
140
+ static properties = {
141
+ ...UIFormElement.properties,
142
+ value: { type: String, default: '#3b82f6', reflect: true },
143
+ format: { type: String, default: 'hex', reflect: true },
144
+ disabled: { type: Boolean, default: false, reflect: true },
145
+ /**
146
+ * v0.4.9 §99h — generation-constraint props (FEEDBACK-02 #7).
147
+ * When set, the picker clamps OKLCH channels to the consumer's
148
+ * declared bounds before committing. Out-of-bound mutations
149
+ * round-trip to the nearest in-bound equivalent + fire a
150
+ * `constraint-clamp` event with `{ axis, requested, clamped, reason }`
151
+ * so the consumer can surface UX feedback (toast, validity warning,
152
+ * scope-drift indicator). Default values disable the constraint.
153
+ */
154
+ maxChroma: { type: Number, default: Infinity, reflect: true, attribute: 'max-chroma' },
155
+ maxL: { type: Number, default: 1, reflect: true, attribute: 'max-l' },
156
+ minL: { type: Number, default: 0, reflect: true, attribute: 'min-l' },
157
+ /** Maximum allowed hue deviation in degrees from the picker's `baseHue`. NaN = no constraint. */
158
+ hueDriftMax: { type: Number, default: NaN, reflect: true, attribute: 'hue-drift-max' },
159
+ /** Reference hue (degrees) for [hue-drift-max]. NaN = use the value parsed at first commit. */
160
+ baseHue: { type: Number, default: NaN, reflect: true, attribute: 'base-hue' },
161
+ };
162
+
163
+ #L = 0.6; #C = 0.15; #H = 230;
164
+ /** Cached reference hue for hue-drift constraint. Set on first commit if [base-hue] isn't authored. */
165
+ #resolvedBaseHue = NaN;
166
+ #bound = false;
167
+ #dragging = null; // 'area' | 'hue' | null
168
+ #internalUpdate = false;
169
+
170
+ // DOM refs
171
+ #canvas = null;
172
+ #ctx = null;
173
+ #areaEl = null;
174
+ #areaThumb = null;
175
+ #hueTrack = null;
176
+ #hueThumb = null;
177
+ #oklchVal = null;
178
+ #hexVal = null;
179
+ #areaResizeObs = null;
180
+ #hueSlider = null;
181
+ #chromaSlider = null;
182
+ #lightnessSlider = null;
183
+ #copyButtons = []; // [{ el, handler }]
184
+ #copyTimer = null;
185
+
186
+ static template = () => null;
187
+
188
+ // ── Stable listeners ──
189
+
190
+ #onSliderInput = (e) => {
191
+ const slider = e.currentTarget;
192
+ const channel = slider.getAttribute('data-channel');
193
+ if (channel === 'hue') this.#H = +slider.value;
194
+ else if (channel === 'chroma') this.#C = +slider.value;
195
+ else if (channel === 'lightness') this.#L = +slider.value;
196
+ this.#commit('input');
197
+ };
198
+
199
+ #onAreaPointerDown = (e) => {
200
+ if (this.disabled) return;
201
+ this.#dragging = 'area';
202
+ this.#areaEl.setPointerCapture(e.pointerId);
203
+ this.#updateAreaFromPointer(e);
204
+ };
205
+
206
+ #onAreaPointerMove = (e) => {
207
+ if (this.#dragging === 'area') this.#updateAreaFromPointer(e);
208
+ };
209
+
210
+ #onAreaPointerUp = () => {
211
+ if (this.#dragging === 'area') { this.#dragging = null; this.#commit('change'); }
212
+ };
213
+
214
+ #onHuePointerDown = (e) => {
215
+ if (this.disabled) return;
216
+ this.#dragging = 'hue';
217
+ this.#hueTrack.setPointerCapture(e.pointerId);
218
+ this.#updateHueFromPointer(e);
219
+ };
220
+
221
+ #onHuePointerMove = (e) => {
222
+ if (this.#dragging === 'hue') this.#updateHueFromPointer(e);
223
+ };
224
+
225
+ #onHuePointerUp = () => {
226
+ if (this.#dragging === 'hue') { this.#dragging = null; this.#commit('change'); }
227
+ };
228
+
229
+ #onAreaKeydown = (e) => {
230
+ if (this.disabled) return;
231
+ const step = e.shiftKey ? 0.05 : 0.01;
232
+ const cStep = e.shiftKey ? 0.04 : 0.004;
233
+ switch (e.key) {
234
+ case 'ArrowRight': e.preventDefault(); this.#C = Math.min(MAX_CHROMA, this.#C + cStep); break;
235
+ case 'ArrowLeft': e.preventDefault(); this.#C = Math.max(0, this.#C - cStep); break;
236
+ case 'ArrowUp': e.preventDefault(); this.#L = Math.min(1, this.#L + step); break;
237
+ case 'ArrowDown': e.preventDefault(); this.#L = Math.max(0, this.#L - step); break;
238
+ default: return;
239
+ }
240
+ this.#commit('change');
241
+ };
242
+
243
+ #onHueKeydown = (e) => {
244
+ if (this.disabled) return;
245
+ const step = e.shiftKey ? 10 : 1;
246
+ switch (e.key) {
247
+ case 'ArrowRight': case 'ArrowUp': e.preventDefault(); this.#H = (this.#H + step) % 360; break;
248
+ case 'ArrowLeft': case 'ArrowDown': e.preventDefault(); this.#H = (this.#H - step + 360) % 360; break;
249
+ default: return;
250
+ }
251
+ this.#commit('change');
252
+ };
253
+
254
+ connected() {
255
+ super.connected();
256
+ this.#parseValue(this.value);
257
+
258
+ if (this.#bound) return;
259
+ this.#bound = true;
260
+
261
+ // ── Build DOM ──
262
+
263
+ // Color area
264
+ this.#areaEl = document.createElement('div');
265
+ this.#areaEl.setAttribute('data-area', '');
266
+ this.#areaEl.setAttribute('tabindex', '0');
267
+ this.#areaEl.setAttribute('role', 'slider');
268
+ this.#areaEl.setAttribute('aria-label', 'Color area: chroma and lightness');
269
+
270
+ this.#canvas = document.createElement('canvas');
271
+ this.#canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:inherit;';
272
+ this.#areaEl.appendChild(this.#canvas);
273
+ this.#ctx = this.#canvas.getContext('2d', { willReadFrequently: true });
274
+
275
+ this.#areaThumb = document.createElement('div');
276
+ this.#areaThumb.setAttribute('data-area-thumb', '');
277
+ this.#areaEl.appendChild(this.#areaThumb);
278
+
279
+ this.appendChild(this.#areaEl);
280
+
281
+ // Slider rows
282
+ const sliders = document.createElement('div');
283
+ sliders.setAttribute('data-sliders', '');
284
+
285
+ const makeSlider = (label, channel, min, max, step) => {
286
+ const slider = document.createElement('slider-ui');
287
+ slider.setAttribute('data-channel', channel);
288
+ slider.setAttribute('label', label);
289
+ slider.setAttribute('min', min);
290
+ slider.setAttribute('max', max);
291
+ slider.setAttribute('step', step);
292
+
293
+ slider.addEventListener('input', this.#onSliderInput);
294
+
295
+ sliders.appendChild(slider);
296
+ return slider;
297
+ };
298
+
299
+ this.#hueSlider = makeSlider('H', 'hue', 0, 360, 1);
300
+ this.#chromaSlider = makeSlider('C', 'chroma', 0, MAX_CHROMA, 0.001);
301
+ this.#lightnessSlider = makeSlider('L', 'lightness', 0, 1, 0.001);
302
+ this.appendChild(sliders);
303
+
304
+ // Hue gradient track
305
+ this.#hueTrack = document.createElement('div');
306
+ this.#hueTrack.setAttribute('data-hue-track', '');
307
+ this.#hueTrack.setAttribute('tabindex', '0');
308
+ this.#hueTrack.setAttribute('role', 'slider');
309
+ this.#hueTrack.setAttribute('aria-label', 'Hue');
310
+ this.#hueThumb = document.createElement('div');
311
+ this.#hueThumb.setAttribute('data-hue-thumb', '');
312
+ this.#hueTrack.appendChild(this.#hueThumb);
313
+ this.appendChild(this.#hueTrack);
314
+
315
+ // Output rows
316
+ const output = document.createElement('div');
317
+ output.setAttribute('data-output', '');
318
+
319
+ const makeOutput = (format) => {
320
+ const group = document.createElement('div');
321
+ group.setAttribute('data-output-group', '');
322
+
323
+ const val = document.createElement('span');
324
+ val.setAttribute('data-output-value', '');
325
+ val.setAttribute('data-format', format);
326
+
327
+ const btn = document.createElement('div');
328
+ btn.setAttribute('role', 'button');
329
+ btn.setAttribute('tabindex', '0');
330
+ btn.setAttribute('data-copy', format);
331
+ btn.setAttribute('aria-label', `Copy ${format}`);
332
+ btn.innerHTML = '<icon-ui name="copy"></icon-ui>';
333
+ const handler = () => this.#copy(format, btn);
334
+ btn.addEventListener('click', handler);
335
+ this.#copyButtons.push({ el: btn, handler });
336
+
337
+ group.appendChild(val);
338
+ group.appendChild(btn);
339
+ output.appendChild(group);
340
+ return val;
341
+ };
342
+
343
+ this.#oklchVal = makeOutput('oklch');
344
+ this.#hexVal = makeOutput('hex');
345
+ this.appendChild(output);
346
+
347
+ // ── Area drag ──
348
+
349
+ this.#areaEl.addEventListener('pointerdown', this.#onAreaPointerDown);
350
+ this.#areaEl.addEventListener('pointermove', this.#onAreaPointerMove);
351
+ this.#areaEl.addEventListener('pointerup', this.#onAreaPointerUp);
352
+
353
+ // ── Hue track drag ──
354
+
355
+ this.#hueTrack.addEventListener('pointerdown', this.#onHuePointerDown);
356
+ this.#hueTrack.addEventListener('pointermove', this.#onHuePointerMove);
357
+ this.#hueTrack.addEventListener('pointerup', this.#onHuePointerUp);
358
+
359
+ // ── Keyboard on area ──
360
+
361
+ this.#areaEl.addEventListener('keydown', this.#onAreaKeydown);
362
+
363
+ // ── Keyboard on hue ──
364
+
365
+ this.#hueTrack.addEventListener('keydown', this.#onHueKeydown);
366
+
367
+ // Observe area resize to redraw canvas.
368
+ // gh#285 — SSR DOM shims (linkedom) have no ResizeObserver global.
369
+ if (typeof ResizeObserver !== 'undefined') {
370
+ this.#areaResizeObs = new ResizeObserver(() => this.#drawArea());
371
+ this.#areaResizeObs.observe(this.#areaEl);
372
+ }
373
+ }
374
+
375
+ disconnected() {
376
+ super.disconnected();
377
+ if (this.#copyTimer != null) {
378
+ clearTimeout(this.#copyTimer);
379
+ this.#copyTimer = null;
380
+ }
381
+ if (this.#areaResizeObs) {
382
+ this.#areaResizeObs.disconnect();
383
+ this.#areaResizeObs = null;
384
+ }
385
+ if (this.#areaEl) {
386
+ this.#areaEl.removeEventListener('pointerdown', this.#onAreaPointerDown);
387
+ this.#areaEl.removeEventListener('pointermove', this.#onAreaPointerMove);
388
+ this.#areaEl.removeEventListener('pointerup', this.#onAreaPointerUp);
389
+ this.#areaEl.removeEventListener('keydown', this.#onAreaKeydown);
390
+ }
391
+ if (this.#hueTrack) {
392
+ this.#hueTrack.removeEventListener('pointerdown', this.#onHuePointerDown);
393
+ this.#hueTrack.removeEventListener('pointermove', this.#onHuePointerMove);
394
+ this.#hueTrack.removeEventListener('pointerup', this.#onHuePointerUp);
395
+ this.#hueTrack.removeEventListener('keydown', this.#onHueKeydown);
396
+ }
397
+ this.#hueSlider?.removeEventListener('input', this.#onSliderInput);
398
+ this.#chromaSlider?.removeEventListener('input', this.#onSliderInput);
399
+ this.#lightnessSlider?.removeEventListener('input', this.#onSliderInput);
400
+ for (const { el, handler } of this.#copyButtons) {
401
+ el.removeEventListener('click', handler);
402
+ }
403
+ this.#copyButtons = [];
404
+ this.#bound = false;
405
+ }
406
+
407
+ // ── Pointer helpers ──
408
+
409
+ #updateAreaFromPointer(e) {
410
+ const rect = this.#areaEl.getBoundingClientRect();
411
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
412
+ const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
413
+ this.#C = x * MAX_CHROMA;
414
+ this.#L = 1 - y;
415
+ this.#commit('input');
416
+ }
417
+
418
+ #updateHueFromPointer(e) {
419
+ const rect = this.#hueTrack.getBoundingClientRect();
420
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
421
+ this.#H = x * 360;
422
+ this.#commit('input');
423
+ }
424
+
425
+ // ── Value parsing ──
426
+
427
+ #parseValue(val) {
428
+ if (!val) return;
429
+ if (val.startsWith('#')) {
430
+ const o = hexToOklch(val);
431
+ this.#L = o.L; this.#C = o.C; this.#H = o.H;
432
+ return;
433
+ }
434
+ if (val.startsWith('oklch(')) {
435
+ // §201 (v0.5.7, FEEDBACK-13 §1 + FEEDBACK-14 §2): accept floats, percent
436
+ // on L, NaN (culori chromaless convention), and 'none' (CSS Color L4
437
+ // powerless-component syntax). Channel-level normalization: NaN/none → 0;
438
+ // percent on L → divide by 100. Per the CSS Color L4 "powerless" cascade
439
+ // (https://www.w3.org/TR/css-color-4/#powerless), zeroing the hue channel
440
+ // is the spec-defined resolution when chroma is 0 or near-0.
441
+ const m = val.match(
442
+ /oklch\(\s*([\d.]+%?|NaN|none)\s+([\d.]+%?|NaN|none)\s+([\d.]+|NaN|none)/i,
443
+ );
444
+ if (m) {
445
+ const parseChan = (s, isL) => {
446
+ if (s === 'none' || /^NaN$/i.test(s)) return 0;
447
+ if (isL && s.endsWith('%')) return +s.slice(0, -1) / 100;
448
+ if (s.endsWith('%')) return +s.slice(0, -1) / 100;
449
+ return +s;
450
+ };
451
+ this.#L = parseChan(m[1], true);
452
+ this.#C = parseChan(m[2], false);
453
+ this.#H = parseChan(m[3], false);
454
+ return;
455
+ }
456
+ // No regex match — warn once per element so consumers can correct.
457
+ // WeakSet dedup matches v0.5.5 §184 §8 button-ui icon-only pattern.
458
+ if (!UIColorArea.#warnedBadParse.has(this)) {
459
+ UIColorArea.#warnedBadParse.add(this);
460
+ // eslint-disable-next-line no-console
461
+ console.warn(
462
+ `<color-area-ui>: could not parse value=${JSON.stringify(val)}. ` +
463
+ `Expected #rrggbb or oklch(L C H) with numeric, NaN, 'none' (CSS L4 ` +
464
+ `powerless), or % channels. Picker is keeping prior state.`,
465
+ );
466
+ }
467
+ }
468
+ }
469
+
470
+ // ── Commit: clamp to consumer constraints + gamut-map + update value + fire events ──
471
+
472
+ #commit(eventType) {
473
+ // Apply consumer-declared constraints BEFORE gamut mapping. Each constraint
474
+ // axis records a `constraint-clamp` event-payload entry when it actually
475
+ // moved a value; we dispatch one event per commit so the consumer can
476
+ // surface a single UX response (toast / validity warning / etc.).
477
+ const clamps = [];
478
+ if (this.#L > this.maxL) {
479
+ clamps.push({ axis: 'l', requested: this.#L, clamped: this.maxL, reason: 'max-l' });
480
+ this.#L = this.maxL;
481
+ }
482
+ if (this.#L < this.minL) {
483
+ clamps.push({ axis: 'l', requested: this.#L, clamped: this.minL, reason: 'min-l' });
484
+ this.#L = this.minL;
485
+ }
486
+ if (this.#C > this.maxChroma) {
487
+ clamps.push({ axis: 'c', requested: this.#C, clamped: this.maxChroma, reason: 'max-chroma' });
488
+ this.#C = this.maxChroma;
489
+ }
490
+ if (Number.isFinite(this.hueDriftMax)) {
491
+ const base = Number.isFinite(this.baseHue)
492
+ ? this.baseHue
493
+ : (Number.isFinite(this.#resolvedBaseHue) ? this.#resolvedBaseHue : this.#H);
494
+ if (!Number.isFinite(this.#resolvedBaseHue)) this.#resolvedBaseHue = base;
495
+ // Hue is circular — find the signed shortest-path drift in [-180, 180].
496
+ let drift = ((this.#H - base + 540) % 360) - 180;
497
+ const limit = this.hueDriftMax;
498
+ if (drift > limit) {
499
+ const requestedH = this.#H;
500
+ this.#H = (base + limit + 360) % 360;
501
+ clamps.push({ axis: 'h', requested: requestedH, clamped: this.#H, reason: 'hue-drift-max' });
502
+ } else if (drift < -limit) {
503
+ const requestedH = this.#H;
504
+ this.#H = (base - limit + 360) % 360;
505
+ clamps.push({ axis: 'h', requested: requestedH, clamped: this.#H, reason: 'hue-drift-max' });
506
+ }
507
+ }
508
+
509
+ const clampedC = gamutMapChroma(this.#L, this.#C, this.#H);
510
+ const hexVal = oklchToHex(this.#L, clampedC, this.#H);
511
+ const oklchStr = this.#oklchStr();
512
+
513
+ this.#internalUpdate = true;
514
+ this.value = this.format === 'oklch' ? oklchStr : hexVal;
515
+ this.syncValue();
516
+
517
+ if (clamps.length) {
518
+ this.dispatchEvent(new CustomEvent('constraint-clamp', { bubbles: true, detail: { clamps } }));
519
+ }
520
+
521
+ const detail = { value: this.value, l: this.#L, c: this.#C, h: this.#H, hex: hexVal, oklch: oklchStr };
522
+ this.dispatchEvent(new CustomEvent(eventType, { bubbles: true, detail }));
523
+ }
524
+
525
+ #oklchStr() {
526
+ return `oklch(${this.#L.toFixed(2)} ${this.#C.toFixed(3)} ${Math.round(this.#H)})`;
527
+ }
528
+
529
+ // ── Copy ──
530
+
531
+ #copy(fmt, btn) {
532
+ const text = fmt === 'oklch'
533
+ ? this.#oklchStr()
534
+ : oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
535
+
536
+ const iconEl = btn.querySelector('icon-ui');
537
+ const onDone = (ok) => {
538
+ if (iconEl) {
539
+ iconEl.setAttribute('name', ok ? 'check' : 'warning');
540
+ if (this.#copyTimer != null) clearTimeout(this.#copyTimer);
541
+ this.#copyTimer = setTimeout(() => {
542
+ this.#copyTimer = null;
543
+ iconEl.setAttribute('name', 'copy');
544
+ }, 1500);
545
+ }
546
+ };
547
+
548
+ if (navigator.clipboard?.writeText) {
549
+ navigator.clipboard.writeText(text).then(
550
+ () => { if (!this.isConnected) return; onDone(true); },
551
+ () => { if (!this.isConnected) return; onDone(false); },
552
+ );
553
+ } else {
554
+ const ta = Object.assign(document.createElement('textarea'), { value: text });
555
+ ta.style.cssText = 'position:fixed;opacity:0';
556
+ document.body.appendChild(ta);
557
+ ta.select();
558
+ onDone(document.execCommand('copy'));
559
+ ta.remove();
560
+ }
561
+ }
562
+
563
+ // ── Canvas rendering ──
564
+
565
+ #drawArea() {
566
+ if (!this.#canvas || !this.#ctx || !this.#areaEl) return;
567
+
568
+ const dpr = Math.min(window.devicePixelRatio || 1, 2) * 0.5;
569
+ const w = Math.round(this.#areaEl.clientWidth * dpr);
570
+ const h = Math.round(this.#areaEl.clientHeight * dpr);
571
+ if (w <= 0 || h <= 0) return;
572
+
573
+ this.#canvas.width = w;
574
+ this.#canvas.height = h;
575
+
576
+ const img = this.#ctx.createImageData(w, h);
577
+ const data = img.data;
578
+ const hue = this.#H;
579
+
580
+ for (let y = 0; y < h; y++) {
581
+ const L = 1 - y / (h - 1);
582
+ for (let x = 0; x < w; x++) {
583
+ const C = (x / (w - 1)) * MAX_CHROMA;
584
+ const clampedC = gamutMapChroma(L, C, hue);
585
+ const [r, g, b] = oklchToRgb(L, clampedC, hue);
586
+ const i = (y * w + x) * 4;
587
+ data[i] = Math.round(r * 255);
588
+ data[i + 1] = Math.round(g * 255);
589
+ data[i + 2] = Math.round(b * 255);
590
+ data[i + 3] = 255;
591
+ }
592
+ }
593
+
594
+ this.#ctx.putImageData(img, 0, 0);
595
+ }
596
+
597
+ // ── Render ──
598
+
599
+ render() {
600
+ if (this.#internalUpdate) {
601
+ this.#internalUpdate = false;
602
+ } else {
603
+ this.#parseValue(this.value);
604
+ }
605
+
606
+ this.#drawArea();
607
+
608
+ // Area thumb
609
+ if (this.#areaThumb) {
610
+ const xPct = (this.#C / MAX_CHROMA) * 100;
611
+ const yPct = (1 - this.#L) * 100;
612
+ this.#areaThumb.style.left = `${xPct}%`;
613
+ this.#areaThumb.style.top = `${yPct}%`;
614
+ this.#areaThumb.style.background = oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
615
+ }
616
+
617
+ // Hue track gradient
618
+ if (this.#hueTrack) {
619
+ const stops = [];
620
+ for (let h = 0; h <= 360; h += 30) stops.push(oklchToHex(0.7, 0.15, h));
621
+ this.#hueTrack.style.background = `linear-gradient(to right, ${stops.join(', ')})`;
622
+ this.#hueTrack.setAttribute('aria-valuenow', String(Math.round(this.#H)));
623
+ }
624
+
625
+ // Hue thumb
626
+ if (this.#hueThumb) {
627
+ this.#hueThumb.style.left = `${(this.#H / 360) * 100}%`;
628
+ this.#hueThumb.style.background = oklchToHex(0.7, 0.15, this.#H);
629
+ }
630
+
631
+ // ARIA
632
+ if (this.#areaEl) {
633
+ this.#areaEl.setAttribute('aria-valuetext', this.#oklchStr());
634
+ }
635
+
636
+ // Slider values
637
+ const hueSlider = this.querySelector('slider-ui[data-channel="hue"]');
638
+ const chromaSlider = this.querySelector('slider-ui[data-channel="chroma"]');
639
+ const lightnessSlider = this.querySelector('slider-ui[data-channel="lightness"]');
640
+ if (hueSlider) hueSlider.value = Math.round(this.#H);
641
+ if (chromaSlider) chromaSlider.value = +this.#C.toFixed(3);
642
+ if (lightnessSlider) lightnessSlider.value = +this.#L.toFixed(3);
643
+
644
+ // Disabled
645
+ if (this.disabled) this.setAttribute('aria-disabled', 'true');
646
+ else this.removeAttribute('aria-disabled');
647
+
648
+ // Output values
649
+ const hex = oklchToHex(this.#L, gamutMapChroma(this.#L, this.#C, this.#H), this.#H);
650
+ if (this.#oklchVal) this.#oklchVal.textContent = this.#oklchStr();
651
+ if (this.#hexVal) this.#hexVal.textContent = hex;
652
+ }
653
+
654
+ onFormReset() {
655
+ this.value = '#3b82f6';
656
+ }
657
+ }