@adia-ai/web-components 0.5.11 → 0.5.13

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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @adia-ai/web-components/color — typed surface for OKLCH color math + APCA contrast
3
+ * computation. §301 (v0.5.12, FEEDBACK-29 re-bucket).
4
+ *
5
+ * Pure functions; no DOM/runtime side-effects. Math sources documented in
6
+ * the corresponding `.js` file.
7
+ */
8
+
9
+ /** Cartesian-OKLab triple. L ∈ [0, 1]; a, b roughly ∈ [-0.4, 0.4]. */
10
+ export interface OKLab {
11
+ L: number;
12
+ a: number;
13
+ b: number;
14
+ }
15
+
16
+ /** Cylindrical-OKLCH triple. L ∈ [0, 1]; C ≥ 0; H ∈ [0, 360) degrees. */
17
+ export interface OKLCH {
18
+ L: number;
19
+ C: number;
20
+ H: number;
21
+ }
22
+
23
+ /** Maximum chroma supported by the gamut-mapping helpers (`0.4`). */
24
+ export const MAX_CHROMA: number;
25
+
26
+ /** Tolerance for in-gamut checks (`0.001`). */
27
+ export const GAMUT_EPSILON: number;
28
+
29
+ // ── OKLCH ↔ OKLab ─────────────────────────────────────────────────────────
30
+
31
+ /** Convert OKLCH (cylindrical) to OKLab (Cartesian). H is in degrees. */
32
+ export function oklchToOklab(L: number, C: number, H: number): OKLab;
33
+
34
+ /** Convert OKLab (Cartesian) to OKLCH (cylindrical). H returned in degrees [0, 360). */
35
+ export function oklabToOklch(L: number, a: number, b: number): OKLCH;
36
+
37
+ // ── OKLab ↔ sRGB (via linear sRGB) ────────────────────────────────────────
38
+
39
+ /**
40
+ * OKLab → linear sRGB. Returns `[r, g, b]` in `[0, 1]` (unclamped — channels
41
+ * may exceed range when the OKLab point is outside sRGB gamut).
42
+ */
43
+ export function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number];
44
+
45
+ /** sRGB transfer function: linear → gamma-encoded sRGB. */
46
+ export function linearToSrgb(c: number): number;
47
+
48
+ /** Inverse sRGB transfer function: gamma-encoded sRGB → linear. */
49
+ export function srgbToLinear(c: number): number;
50
+
51
+ /**
52
+ * OKLCH → sRGB clamped to `[0, 1]`. Returns `[r, g, b]`. Out-of-gamut
53
+ * colors are channel-clamped; use `gamutMapChroma()` first for the closest
54
+ * in-gamut color via chroma reduction.
55
+ */
56
+ export function oklchToRgb(L: number, C: number, H: number): [number, number, number];
57
+
58
+ // ── Hex ↔ OKLCH ───────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Convert sRGB `[r, g, b]` (each in `[0, 1]`) to hex string `#rrggbb`.
62
+ * Channel values are rounded to the nearest byte; out-of-range clamped.
63
+ */
64
+ export function rgbToHex(r: number, g: number, b: number): string;
65
+
66
+ /** OKLCH → hex string `#rrggbb`. Channel-clamped to sRGB gamut. */
67
+ export function oklchToHex(L: number, C: number, H: number): string;
68
+
69
+ /**
70
+ * Hex string `#rgb` or `#rrggbb` → OKLCH. H in degrees [0, 360).
71
+ * Accepts both 3- and 6-digit shorthand; tolerant of leading `#`.
72
+ */
73
+ export function hexToOklch(hex: string): OKLCH;
74
+
75
+ // ── Gamut checking + mapping ──────────────────────────────────────────────
76
+
77
+ /** Returns true if all 3 sRGB channels lie in `[0, 1]` (tolerance `0.001`). */
78
+ export function isInGamut(r: number, g: number, b: number): boolean;
79
+
80
+ /** Returns true if the OKLCH triple is inside sRGB gamut. */
81
+ export function isOklchInGamut(L: number, C: number, H: number): boolean;
82
+
83
+ /**
84
+ * Bisect chroma to find the largest value that keeps `(L, _, H)` in sRGB
85
+ * gamut. Used when the consumer requests a chroma outside the displayable
86
+ * range. 8 bisection iterations — completes in <1ms.
87
+ */
88
+ export function gamutMapChroma(L: number, C: number, H: number): number;
89
+
90
+ // ── Display-P3 gamut (v0.5.13 §-TBD, FB-31) ──────────────────────────────
91
+
92
+ /**
93
+ * Returns true if the OKLCH triple is inside Display-P3 gamut (tolerance
94
+ * `0.001`). sRGB-linear → P3-linear conversion uses the CSS Color 4 § 12.3
95
+ * matrix (D65 → D65; no Bradford adaptation needed).
96
+ */
97
+ export function inP3Gamut(L: number, C: number, H: number): boolean;
98
+
99
+ /**
100
+ * Bisect chroma to find the largest value that keeps `(L, _, H)` in
101
+ * Display-P3 gamut. 8 bisection iterations — same shape as the sRGB
102
+ * `gamutMapChroma`. Used by P3-aware design tooling.
103
+ */
104
+ export function gamutMapChromaP3(L: number, C: number, H: number): number;
105
+
106
+ // ── APCA (advanced perceptual contrast algorithm) ─────────────────────────
107
+
108
+ /**
109
+ * Compute APCA contrast (Lc) between a foreground hex color and a
110
+ * background hex color. Returns the signed Lc value:
111
+ * - Positive = dark text on light bg ("normal polarity")
112
+ * - Negative = light text on dark bg ("reverse polarity")
113
+ *
114
+ * Typical thresholds (from APCA's "Bronze Simple Mode"):
115
+ * - Body text: |Lc| ≥ 75 (4.5:1 WCAG analog)
116
+ * - Large/bold text: |Lc| ≥ 60 (3:1 WCAG analog)
117
+ * - Incidental: |Lc| ≥ 45 (chrome, decorative)
118
+ *
119
+ * @see https://www.myndex.com/APCA/
120
+ */
121
+ export function apcaContrast(fgHex: string, bgHex: string): number;
122
+
123
+ /**
124
+ * Convenience: choose a foreground (light or dark) that maximizes APCA
125
+ * contrast against the given background hex. Returns either '#000000' or
126
+ * '#ffffff'. Used by `<swatch-ui auto-contrast>`'s OKLab-L probe.
127
+ */
128
+ export function pickContrastingFg(bgHex: string): '#000000' | '#ffffff';
129
+
130
+ // ── LCH-space distance (perceptual distance) ──────────────────────────────
131
+
132
+ /**
133
+ * Compute perceptual distance between two OKLCH triples.
134
+ * Returns Euclidean distance in OKLab space — perceptually uniform.
135
+ * Useful for "closest swatch in a palette" lookups.
136
+ */
137
+ export function oklchDistance(a: OKLCH, b: OKLCH): number;
package/color/index.js ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * @adia-ai/web-components/color — color-space conversion + gamut utilities
3
+ * shared between primitives (color-picker, swatch, contrast-badge) and
4
+ * available to consumers as a standalone subpath.
5
+ *
6
+ * §301 (v0.5.12, FEEDBACK-29 re-bucket from v0.6.0). Pure functions; no
7
+ * DOM/runtime side-effects. Pre-§301 these helpers lived inline at
8
+ * `components/color-picker/class.js:34-121`. Extracted into a shared
9
+ * subpath so consumers (Tokens Studio, custom palette tools) don't
10
+ * re-implement OKLCH math.
11
+ *
12
+ * Math sources:
13
+ * - OKLab ↔ linear-sRGB matrix: Björn Ottosson's canonical coefficients.
14
+ * - Gamut-mapping: chroma-clamping bisection (8 iterations; matches
15
+ * the color-picker's on-track clamp behavior).
16
+ *
17
+ * @see https://bottosson.github.io/posts/oklab/
18
+ * @see https://www.w3.org/TR/css-color-4/#color-conversion-code
19
+ */
20
+
21
+ const MAX_CHROMA = 0.4;
22
+ const GAMUT_EPSILON = 0.001;
23
+
24
+ // ── OKLCH ↔ OKLab ─────────────────────────────────────────────────────────
25
+
26
+ /** Convert OKLCH (cylindrical) to OKLab (Cartesian). H is in degrees. */
27
+ export function oklchToOklab(L, C, H) {
28
+ const hRad = H * Math.PI / 180;
29
+ return { L, a: C * Math.cos(hRad), b: C * Math.sin(hRad) };
30
+ }
31
+
32
+ /** Convert OKLab (Cartesian) to OKLCH (cylindrical). H returned in degrees [0, 360). */
33
+ export function oklabToOklch(L, a, b) {
34
+ const C = Math.sqrt(a * a + b * b);
35
+ let H = Math.atan2(b, a) * 180 / Math.PI;
36
+ if (H < 0) H += 360;
37
+ return { L, C, H };
38
+ }
39
+
40
+ // ── OKLab ↔ sRGB (via linear sRGB) ────────────────────────────────────────
41
+
42
+ /**
43
+ * OKLab → linear sRGB. Returns `[r, g, b]` in `[0, 1]` (unclamped).
44
+ * Use `linearToSrgb()` channel-by-channel to apply the sRGB transfer
45
+ * function; use `isInGamut()` to check if the result lies inside sRGB.
46
+ */
47
+ export function oklabToLinearSrgb(L, a, b) {
48
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
49
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
50
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
51
+ const l = l_ * l_ * l_;
52
+ const m = m_ * m_ * m_;
53
+ const s = s_ * s_ * s_;
54
+ return [
55
+ +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
56
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
57
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
58
+ ];
59
+ }
60
+
61
+ /** sRGB transfer function: linear → gamma-encoded sRGB. */
62
+ export function linearToSrgb(c) {
63
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
64
+ }
65
+
66
+ /** Inverse sRGB transfer function: gamma-encoded sRGB → linear. */
67
+ export function srgbToLinear(c) {
68
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
69
+ }
70
+
71
+ /**
72
+ * OKLCH → sRGB clamped to `[0, 1]`. Returns `[r, g, b]`. Out-of-gamut
73
+ * colors are channel-clamped (not gamut-mapped); use `gamutMapChroma()`
74
+ * first if you need the closest in-gamut color.
75
+ */
76
+ export function oklchToRgb(L, C, H) {
77
+ const { a, b } = oklchToOklab(L, C, H);
78
+ const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
79
+ return [
80
+ Math.max(0, Math.min(1, linearToSrgb(lr))),
81
+ Math.max(0, Math.min(1, linearToSrgb(lg))),
82
+ Math.max(0, Math.min(1, linearToSrgb(lb))),
83
+ ];
84
+ }
85
+
86
+ // ── Hex ↔ OKLCH ───────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Convert sRGB `[r, g, b]` (each in `[0, 1]`) to hex string `#rrggbb`.
90
+ * Channel values are rounded to the nearest byte; out-of-range clamped.
91
+ */
92
+ export function rgbToHex(r, g, b) {
93
+ const h = (c) => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0');
94
+ return `#${h(r)}${h(g)}${h(b)}`;
95
+ }
96
+
97
+ /** OKLCH → hex string `#rrggbb`. Channel-clamped to sRGB gamut. */
98
+ export function oklchToHex(L, C, H) {
99
+ const [r, g, b] = oklchToRgb(L, C, H);
100
+ return rgbToHex(r, g, b);
101
+ }
102
+
103
+ /**
104
+ * Hex string `#rgb` or `#rrggbb` → OKLCH `{ L, C, H }`. H in degrees [0, 360).
105
+ * Accepts both 3- and 6-digit shorthand; tolerant of leading `#`.
106
+ */
107
+ export function hexToOklch(hex) {
108
+ hex = hex.replace('#', '');
109
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
110
+ const r = srgbToLinear(parseInt(hex.slice(0, 2), 16) / 255);
111
+ const g = srgbToLinear(parseInt(hex.slice(2, 4), 16) / 255);
112
+ const b = srgbToLinear(parseInt(hex.slice(4, 6), 16) / 255);
113
+ const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
114
+ const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
115
+ const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
116
+ const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
117
+ const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
118
+ const bv = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
119
+ return oklabToOklch(L, a, bv);
120
+ }
121
+
122
+ // ── Gamut checking + mapping ──────────────────────────────────────────────
123
+
124
+ /** Returns true if all 3 sRGB channels lie in `[0, 1]` (tolerance `0.001`). */
125
+ export function isInGamut(r, g, b) {
126
+ return r >= -GAMUT_EPSILON && r <= 1 + GAMUT_EPSILON
127
+ && g >= -GAMUT_EPSILON && g <= 1 + GAMUT_EPSILON
128
+ && b >= -GAMUT_EPSILON && b <= 1 + GAMUT_EPSILON;
129
+ }
130
+
131
+ /** Returns true if the OKLCH triple is inside sRGB gamut. */
132
+ export function isOklchInGamut(L, C, H) {
133
+ const { a, b } = oklchToOklab(L, C, H);
134
+ const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
135
+ return isInGamut(linearToSrgb(lr), linearToSrgb(lg), linearToSrgb(lb));
136
+ }
137
+
138
+ /**
139
+ * Bisect chroma to find the largest value that keeps `(L, _, H)` in sRGB
140
+ * gamut. Used when the consumer requests a chroma outside the displayable
141
+ * range (e.g., dragging the picker's chroma slider past the gamut boundary).
142
+ *
143
+ * 8 bisection iterations — matches the color-picker's on-track clamp
144
+ * behavior + completes in <1ms.
145
+ */
146
+ export function gamutMapChroma(L, C, H) {
147
+ if (isOklchInGamut(L, C, H)) return C;
148
+ let lo = 0, hi = C;
149
+ for (let i = 0; i < 8; i++) {
150
+ const mid = (lo + hi) / 2;
151
+ if (isOklchInGamut(L, mid, H)) lo = mid;
152
+ else hi = mid;
153
+ }
154
+ return lo;
155
+ }
156
+
157
+ // ── Display-P3 gamut ──────────────────────────────────────────────────────
158
+ //
159
+ // v0.5.13 §-TBD (FB-31). The v0.6.0 §301 plan-doc spec committed
160
+ // `inP3Gamut(o)` + `gamutMapOklch(o, 'p3')`; the v0.5.12 §301 ship dropped
161
+ // both (sRGB-only surface). FB-31 closes the gap with explicit P3 helpers
162
+ // matching the existing scalar-channels signature shape.
163
+ //
164
+ // sRGB-linear → Display-P3-linear matrix sourced from CSS Color 4 § 12.3
165
+ // (assumes both spaces share the D65 white point, which is true post-2018
166
+ // browser implementations; no Bradford chromatic adaptation needed).
167
+
168
+ function linearSrgbToLinearP3(r, g, b) {
169
+ return [
170
+ 0.8224621 * r + 0.1775380 * g + 0.0000000 * b,
171
+ 0.0331941 * r + 0.9668058 * g + 0.0000001 * b,
172
+ 0.0170827 * r + 0.0723974 * g + 0.9105199 * b,
173
+ ];
174
+ }
175
+
176
+ /** Returns true if the OKLCH triple is inside Display-P3 gamut (tolerance `0.001`). */
177
+ export function inP3Gamut(L, C, H) {
178
+ const { a, b } = oklchToOklab(L, C, H);
179
+ const [lr, lg, lb] = oklabToLinearSrgb(L, a, b);
180
+ const [p3r, p3g, p3b] = linearSrgbToLinearP3(lr, lg, lb);
181
+ return isInGamut(linearToSrgb(p3r), linearToSrgb(p3g), linearToSrgb(p3b));
182
+ }
183
+
184
+ /**
185
+ * Bisect chroma to find the largest value that keeps `(L, _, H)` in
186
+ * Display-P3 gamut. 8 bisection iterations — same shape as the sRGB
187
+ * `gamutMapChroma`. Used by P3-aware design tooling (Tokens Studio,
188
+ * theme designers targeting P3-display laptops + iPads).
189
+ */
190
+ export function gamutMapChromaP3(L, C, H) {
191
+ if (inP3Gamut(L, C, H)) return C;
192
+ let lo = 0, hi = C;
193
+ for (let i = 0; i < 8; i++) {
194
+ const mid = (lo + hi) / 2;
195
+ if (inP3Gamut(L, mid, H)) lo = mid;
196
+ else hi = mid;
197
+ }
198
+ return lo;
199
+ }
200
+
201
+ // ── APCA (advanced perceptual contrast algorithm) ─────────────────────────
202
+ //
203
+ // Source of truth: Andrew Somers' SACAM specification + apca-w3@0.1.9 (the
204
+ // W3C-pinned reference impl). The soft-clamp threshold is `0.022` for both
205
+ // fg + bg luminance values (per `SAPC_BLACK_THRESHOLD` in apca-w3 source).
206
+ // The 1.414 exponent is the APCA "BLACK_CLAMP" rescale constant.
207
+ //
208
+ // v0.5.13 §-TBD (FB-35): corrected the soft-clamp constant. v0.5.12 ship
209
+ // used 0.06 (declared as APCA_LO_CLAMP) by mistake, producing Lc 99.49 for
210
+ // black-on-white where canonical is 106.04 (-6.5pt bias for any pair
211
+ // involving near-black). Constants APCA_LO_FG / APCA_LO_BG were declared
212
+ // correctly but never read; the 0.06 constant was a stray from an earlier
213
+ // draft. apcaSrgbY() unchanged.
214
+
215
+ const APCA_LO_BG = 0.022;
216
+ const APCA_LO_FG = 0.022;
217
+ const APCA_BLACK_CLAMP = 1.414;
218
+
219
+ function apcaSrgbY(rNorm, gNorm, bNorm) {
220
+ // sRGB → relative luminance via APCA Y (different coefficients than WCAG).
221
+ // Per the SACAM specification (Andrew Somers, github.com/Myndex/apca-w3).
222
+ const r = Math.pow(rNorm, 2.4);
223
+ const g = Math.pow(gNorm, 2.4);
224
+ const b = Math.pow(bNorm, 2.4);
225
+ return 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
226
+ }
227
+
228
+ /**
229
+ * Compute APCA contrast (Lc) between a foreground hex color and a
230
+ * background hex color. Returns the signed Lc value:
231
+ * - Positive = dark text on light bg ("normal polarity")
232
+ * - Negative = light text on dark bg ("reverse polarity")
233
+ *
234
+ * Typical thresholds (from APCA's "Bronze Simple Mode"):
235
+ * - Body text: |Lc| ≥ 75 (4.5:1 WCAG analog)
236
+ * - Large/bold text: |Lc| ≥ 60 (3:1 WCAG analog)
237
+ * - Incidental: |Lc| ≥ 45 (chrome, decorative)
238
+ *
239
+ * @see https://www.myndex.com/APCA/
240
+ */
241
+ export function apcaContrast(fgHex, bgHex) {
242
+ const parse = (hex) => {
243
+ const h = hex.replace('#', '');
244
+ const norm = (s) => parseInt(s, 16) / 255;
245
+ return {
246
+ r: norm(h.slice(0, 2)),
247
+ g: norm(h.slice(2, 4)),
248
+ b: norm(h.slice(4, 6)),
249
+ };
250
+ };
251
+ const fg = parse(fgHex);
252
+ const bg = parse(bgHex);
253
+ let Yfg = apcaSrgbY(fg.r, fg.g, fg.b);
254
+ let Ybg = apcaSrgbY(bg.r, bg.g, bg.b);
255
+
256
+ // Soft clamp near black per APCA spec — threshold 0.022 per SACAM /
257
+ // apca-w3@0.1.9 (`SAPC_BLACK_THRESHOLD`).
258
+ if (Yfg < APCA_LO_FG) Yfg += Math.pow(APCA_LO_FG - Yfg, APCA_BLACK_CLAMP);
259
+ if (Ybg < APCA_LO_BG) Ybg += Math.pow(APCA_LO_BG - Ybg, APCA_BLACK_CLAMP);
260
+
261
+ let Lc;
262
+ if (Ybg > Yfg) {
263
+ // Dark text on light bg ("normal polarity") → positive Lc
264
+ Lc = (Math.pow(Ybg, 0.56) - Math.pow(Yfg, 0.57)) * 1.14 * 100;
265
+ if (Lc < 7.5) Lc = 0; // below perception floor
266
+ else Lc = Lc - 0.027 * 100;
267
+ } else {
268
+ // Light text on dark bg ("reverse polarity") → negative Lc
269
+ Lc = (Math.pow(Ybg, 0.65) - Math.pow(Yfg, 0.62)) * 1.14 * 100;
270
+ if (Lc > -7.5) Lc = 0;
271
+ else Lc = Lc + 0.027 * 100;
272
+ }
273
+ return Lc;
274
+ }
275
+
276
+ /**
277
+ * Convenience: choose a foreground (light or dark) that maximizes APCA
278
+ * contrast against the given background hex. Returns either '#000000' or
279
+ * '#ffffff'. Used by `<swatch-ui auto-contrast>`'s OKLab-L probe.
280
+ */
281
+ export function pickContrastingFg(bgHex) {
282
+ const onDark = apcaContrast('#ffffff', bgHex);
283
+ const onLight = apcaContrast('#000000', bgHex);
284
+ return Math.abs(onDark) >= Math.abs(onLight) ? '#ffffff' : '#000000';
285
+ }
286
+
287
+ // ── LCH-space distance (perceptual distance) ──────────────────────────────
288
+
289
+ /**
290
+ * Compute perceptual distance between two OKLCH triples.
291
+ * Returns Euclidean distance in OKLab space — perceptually uniform.
292
+ * Useful for "closest swatch in a palette" lookups.
293
+ */
294
+ export function oklchDistance(a, b) {
295
+ const aLab = oklchToOklab(a.L, a.C, a.H);
296
+ const bLab = oklchToOklab(b.L, b.C, b.H);
297
+ const dL = aLab.L - bLab.L;
298
+ const dA = aLab.a - bLab.a;
299
+ const dB = aLab.b - bLab.b;
300
+ return Math.sqrt(dL * dL + dA * dA + dB * dB);
301
+ }
302
+
303
+ // ── Re-export constants ──────────────────────────────────────────────────
304
+
305
+ export { MAX_CHROMA, GAMUT_EPSILON };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Non-side-effect class export for `<color-input-ui>`.
3
+ *
4
+ * Importing this file gives you the class without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or
6
+ * selective composition.
7
+ *
8
+ * The auto-register path stays at
9
+ * `@adia-ai/web-components/components/color-input` (which imports this
10
+ * file + calls `defineIfFree()`).
11
+ *
12
+ * @see ../../USAGE.md#registration--auto-vs-explicit
13
+ */
14
+
15
+ /**
16
+ * <color-input-ui value="#3b82f6" format="hex" name="brand"></color-input-ui>
17
+ *
18
+ * Compact form-bearing color input. §302 (v0.5.12, FEEDBACK-29 re-bucket).
19
+ *
20
+ * Canonicalizes the USAGE.md §221f recipe (popover + button + color-picker)
21
+ * into a single form-associated tag. Builds its inner DOM in `connected()`
22
+ * as light-DOM children:
23
+ *
24
+ * <color-input-ui>
25
+ * <popover-ui trigger="click" placement="bottom-start">
26
+ * <button-ui slot="trigger" variant="outline">
27
+ * <span class="color-input__swatch"></span>
28
+ * <span class="color-input__value"></span>
29
+ * </button-ui>
30
+ * <color-picker-ui slot="content"></color-picker-ui>
31
+ * </popover-ui>
32
+ * </color-input-ui>
33
+ *
34
+ * Form-associated via UIFormElement + ElementInternals.
35
+ */
36
+
37
+ import { UIFormElement } from '../../core/form.js';
38
+
39
+ export class UIColorInput extends UIFormElement {
40
+ static get properties() {
41
+ return {
42
+ ...UIFormElement.properties,
43
+ value: { type: String, default: '#3b82f6', reflect: true },
44
+ format: { type: String, default: 'hex', reflect: true },
45
+ placement: { type: String, default: 'bottom-start', reflect: true },
46
+ open: { type: Boolean, default: false, reflect: true },
47
+ // Generation-constraint props forwarded to the inner <color-picker-ui>.
48
+ // v0.5.13 §-TBD (FB-33 §1) — full parity with color-picker's 5 constraint
49
+ // props (`maxChroma` / `maxL` / `minL` / `hueDriftMax` / `baseHue`) so
50
+ // <color-input-ui> adoption doesn't force a UX regression on consumers
51
+ // already using L-range / chroma / hue-drift constraints.
52
+ maxChroma: { type: Number, default: Infinity, reflect: true },
53
+ maxL: { type: Number, default: 1, reflect: true },
54
+ minL: { type: Number, default: 0, reflect: true },
55
+ hueDriftMax: { type: Number, default: NaN, reflect: true },
56
+ baseHue: { type: Number, default: NaN, reflect: true },
57
+ };
58
+ }
59
+
60
+ #popover = null;
61
+ #button = null;
62
+ #picker = null;
63
+ #swatch = null;
64
+ #valueLabel = null;
65
+ #wired = false;
66
+
67
+ connected() {
68
+ this.#mount();
69
+ this.#wire();
70
+ this.#syncFromValue();
71
+ this.syncValue(this.value);
72
+ }
73
+
74
+ render() {
75
+ this.#syncFromValue();
76
+ if (this.#popover && this.#popover.open !== this.open) {
77
+ this.#popover.open = this.open;
78
+ }
79
+ if (this.#picker) {
80
+ if (this.#picker.format !== this.format) this.#picker.format = this.format;
81
+ // Forward live changes to the 5 generation-constraint props (FB-33 §1).
82
+ // Object.is handles the NaN-equality case correctly so `baseHue`/
83
+ // `hueDriftMax` don't reassign on every render.
84
+ if (!Object.is(this.#picker.maxChroma, this.maxChroma)) this.#picker.maxChroma = this.maxChroma;
85
+ if (!Object.is(this.#picker.maxL, this.maxL)) this.#picker.maxL = this.maxL;
86
+ if (!Object.is(this.#picker.minL, this.minL)) this.#picker.minL = this.minL;
87
+ if (!Object.is(this.#picker.hueDriftMax, this.hueDriftMax)) this.#picker.hueDriftMax = this.hueDriftMax;
88
+ if (!Object.is(this.#picker.baseHue, this.baseHue)) this.#picker.baseHue = this.baseHue;
89
+ }
90
+ if (this.#button) {
91
+ if (this.disabled) this.#button.setAttribute('disabled', '');
92
+ else this.#button.removeAttribute('disabled');
93
+ }
94
+ }
95
+
96
+ // ── Public API ──
97
+
98
+ showPicker() { this.open = true; }
99
+ hidePicker() { this.open = false; }
100
+
101
+ // ── Internal ──
102
+
103
+ #mount() {
104
+ if (this.firstElementChild) {
105
+ this.#popover = this.querySelector(':scope > popover-ui');
106
+ this.#button = this.querySelector(':scope > popover-ui > button-ui[slot="trigger"]');
107
+ this.#picker = this.querySelector(':scope > popover-ui > color-picker-ui[slot="content"]');
108
+ this.#swatch = this.querySelector('.color-input__swatch');
109
+ this.#valueLabel = this.querySelector('.color-input__value');
110
+ if (this.#popover && this.#button && this.#picker) return;
111
+ }
112
+
113
+ const popover = document.createElement('popover-ui');
114
+ popover.setAttribute('trigger', 'click');
115
+ popover.setAttribute('placement', this.placement);
116
+
117
+ const button = document.createElement('button-ui');
118
+ button.setAttribute('slot', 'trigger');
119
+ button.setAttribute('variant', 'outline');
120
+ button.setAttribute('aria-haspopup', 'dialog');
121
+ button.setAttribute('aria-label', this.getAttribute('aria-label') || 'Pick a color');
122
+
123
+ const swatch = document.createElement('span');
124
+ swatch.className = 'color-input__swatch';
125
+ swatch.setAttribute('aria-hidden', 'true');
126
+
127
+ const valueLabel = document.createElement('span');
128
+ valueLabel.className = 'color-input__value';
129
+
130
+ button.append(swatch, valueLabel);
131
+
132
+ const picker = document.createElement('color-picker-ui');
133
+ picker.setAttribute('slot', 'content');
134
+ picker.setAttribute('format', this.format);
135
+ picker.setAttribute('value', this.value);
136
+ // Set generation-constraint props directly (numeric — attribute-reflection
137
+ // would round-trip but properties cover NaN/Infinity correctly).
138
+ picker.maxChroma = this.maxChroma;
139
+ picker.maxL = this.maxL;
140
+ picker.minL = this.minL;
141
+ picker.hueDriftMax = this.hueDriftMax;
142
+ picker.baseHue = this.baseHue;
143
+
144
+ popover.append(button, picker);
145
+ this.append(popover);
146
+
147
+ this.#popover = popover;
148
+ this.#button = button;
149
+ this.#picker = picker;
150
+ this.#swatch = swatch;
151
+ this.#valueLabel = valueLabel;
152
+ }
153
+
154
+ #wire() {
155
+ if (this.#wired) return;
156
+ this.#wired = true;
157
+
158
+ this.#picker.addEventListener('change', this.#onPickerChange);
159
+ this.#picker.addEventListener('input', this.#onPickerInput);
160
+
161
+ // Track popover open/close so the host's `open` prop reflects reality
162
+ // even when the user dismisses via ESC / outside-click.
163
+ const sync = () => {
164
+ const open = !!this.#popover?.open;
165
+ if (this.open !== open) this.open = open;
166
+ };
167
+ this.#popover.addEventListener('toggle', sync);
168
+ new MutationObserver(sync).observe(this.#popover, {
169
+ attributes: true,
170
+ attributeFilter: ['open'],
171
+ });
172
+ }
173
+
174
+ #onPickerChange = (e) => {
175
+ this.#commit(e.detail, 'change');
176
+ };
177
+
178
+ #onPickerInput = (e) => {
179
+ this.#commit(e.detail, 'input');
180
+ };
181
+
182
+ #commit(detail, kind) {
183
+ if (!detail) return;
184
+ const next = this.format === 'oklch' ? detail.oklch : detail.hex;
185
+ if (next && next !== this.value) {
186
+ this.value = next;
187
+ this.syncValue(next);
188
+ }
189
+ this.#syncFromValue();
190
+ // Forward the inner picker's parsed OKLCH channel scalars (v0.5.13 §-TBD,
191
+ // FB-33 §1-adjacent). Consumers writing OKLCH-native logic get the parsed
192
+ // {l, c, h} for free, matching <color-picker-ui>'s detail shape.
193
+ this.dispatchEvent(new CustomEvent(kind, {
194
+ bubbles: true,
195
+ composed: true,
196
+ detail: {
197
+ value: next ?? this.value,
198
+ hex: detail.hex,
199
+ oklch: detail.oklch,
200
+ l: detail.l,
201
+ c: detail.c,
202
+ h: detail.h,
203
+ },
204
+ }));
205
+ }
206
+
207
+ #syncFromValue() {
208
+ if (this.#swatch) {
209
+ this.#swatch.style.setProperty('--swatch-color', this.value);
210
+ }
211
+ if (this.#valueLabel) {
212
+ this.#valueLabel.textContent = this.value;
213
+ }
214
+ }
215
+
216
+ disconnected() {
217
+ this.#picker?.removeEventListener('change', this.#onPickerChange);
218
+ this.#picker?.removeEventListener('input', this.#onPickerInput);
219
+ this.#wired = false;
220
+ }
221
+ }