@adia-ai/web-components 0.5.11 → 0.5.12
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.
- package/CHANGELOG.md +2207 -0
- package/color/index.d.ts +121 -0
- package/color/index.js +252 -0
- package/components/color-input/class.js +186 -0
- package/components/color-input/color-input.a2ui.json +172 -0
- package/components/color-input/color-input.css +47 -0
- package/components/color-input/color-input.d.ts +71 -0
- package/components/color-input/color-input.js +26 -0
- package/components/color-input/color-input.yaml +145 -0
- package/components/index.js +1 -0
- package/components/stat/stat-ui.d.ts +38 -0
- package/components/swatch/class.js +8 -0
- package/components/swatch/swatch.a2ui.json +10 -1
- package/components/swatch/swatch.css +32 -0
- package/components/swatch/swatch.d.ts +13 -3
- package/components/swatch/swatch.yaml +21 -3
- package/package.json +9 -2
- package/styles/components.css +1 -0
package/color/index.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
// ── APCA (advanced perceptual contrast algorithm) ─────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Compute APCA contrast (Lc) between a foreground hex color and a
|
|
94
|
+
* background hex color. Returns the signed Lc value:
|
|
95
|
+
* - Positive = dark text on light bg ("normal polarity")
|
|
96
|
+
* - Negative = light text on dark bg ("reverse polarity")
|
|
97
|
+
*
|
|
98
|
+
* Typical thresholds (from APCA's "Bronze Simple Mode"):
|
|
99
|
+
* - Body text: |Lc| ≥ 75 (4.5:1 WCAG analog)
|
|
100
|
+
* - Large/bold text: |Lc| ≥ 60 (3:1 WCAG analog)
|
|
101
|
+
* - Incidental: |Lc| ≥ 45 (chrome, decorative)
|
|
102
|
+
*
|
|
103
|
+
* @see https://www.myndex.com/APCA/
|
|
104
|
+
*/
|
|
105
|
+
export function apcaContrast(fgHex: string, bgHex: string): number;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convenience: choose a foreground (light or dark) that maximizes APCA
|
|
109
|
+
* contrast against the given background hex. Returns either '#000000' or
|
|
110
|
+
* '#ffffff'. Used by `<swatch-ui auto-contrast>`'s OKLab-L probe.
|
|
111
|
+
*/
|
|
112
|
+
export function pickContrastingFg(bgHex: string): '#000000' | '#ffffff';
|
|
113
|
+
|
|
114
|
+
// ── LCH-space distance (perceptual distance) ──────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compute perceptual distance between two OKLCH triples.
|
|
118
|
+
* Returns Euclidean distance in OKLab space — perceptually uniform.
|
|
119
|
+
* Useful for "closest swatch in a palette" lookups.
|
|
120
|
+
*/
|
|
121
|
+
export function oklchDistance(a: OKLCH, b: OKLCH): number;
|
package/color/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
// ── APCA (advanced perceptual contrast algorithm) ─────────────────────────
|
|
158
|
+
|
|
159
|
+
const APCA_NORM_L = 0.027;
|
|
160
|
+
const APCA_LO_BG = 0.022;
|
|
161
|
+
const APCA_LO_FG = 0.022;
|
|
162
|
+
const APCA_REVERSE_FACTOR = 1.14;
|
|
163
|
+
const APCA_NORM_FACTOR = 1.14;
|
|
164
|
+
const APCA_LO_CLAMP = 0.06;
|
|
165
|
+
const APCA_RESCALE = 1.14;
|
|
166
|
+
|
|
167
|
+
function apcaSrgbY(rNorm, gNorm, bNorm) {
|
|
168
|
+
// sRGB → relative luminance via APCA Y (different coefficients than WCAG).
|
|
169
|
+
// Per the SACAM specification (Andrew Somers, github.com/Myndex/apca-w3).
|
|
170
|
+
const r = Math.pow(rNorm, 2.4);
|
|
171
|
+
const g = Math.pow(gNorm, 2.4);
|
|
172
|
+
const b = Math.pow(bNorm, 2.4);
|
|
173
|
+
return 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute APCA contrast (Lc) between a foreground hex color and a
|
|
178
|
+
* background hex color. Returns the signed Lc value:
|
|
179
|
+
* - Positive = dark text on light bg ("normal polarity")
|
|
180
|
+
* - Negative = light text on dark bg ("reverse polarity")
|
|
181
|
+
*
|
|
182
|
+
* Typical thresholds (from APCA's "Bronze Simple Mode"):
|
|
183
|
+
* - Body text: |Lc| ≥ 75 (4.5:1 WCAG analog)
|
|
184
|
+
* - Large/bold text: |Lc| ≥ 60 (3:1 WCAG analog)
|
|
185
|
+
* - Incidental: |Lc| ≥ 45 (chrome, decorative)
|
|
186
|
+
*
|
|
187
|
+
* @see https://www.myndex.com/APCA/
|
|
188
|
+
*/
|
|
189
|
+
export function apcaContrast(fgHex, bgHex) {
|
|
190
|
+
const parse = (hex) => {
|
|
191
|
+
const h = hex.replace('#', '');
|
|
192
|
+
const norm = (s) => parseInt(s, 16) / 255;
|
|
193
|
+
return {
|
|
194
|
+
r: norm(h.slice(0, 2)),
|
|
195
|
+
g: norm(h.slice(2, 4)),
|
|
196
|
+
b: norm(h.slice(4, 6)),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const fg = parse(fgHex);
|
|
200
|
+
const bg = parse(bgHex);
|
|
201
|
+
let Yfg = apcaSrgbY(fg.r, fg.g, fg.b);
|
|
202
|
+
let Ybg = apcaSrgbY(bg.r, bg.g, bg.b);
|
|
203
|
+
|
|
204
|
+
// Soft clamp near black per APCA.
|
|
205
|
+
if (Yfg < APCA_LO_CLAMP) Yfg += Math.pow(APCA_LO_CLAMP - Yfg, 1.414);
|
|
206
|
+
if (Ybg < APCA_LO_CLAMP) Ybg += Math.pow(APCA_LO_CLAMP - Ybg, 1.414);
|
|
207
|
+
|
|
208
|
+
let Lc;
|
|
209
|
+
if (Ybg > Yfg) {
|
|
210
|
+
// Dark text on light bg ("normal polarity") → positive Lc
|
|
211
|
+
Lc = (Math.pow(Ybg, 0.56) - Math.pow(Yfg, 0.57)) * 1.14 * 100;
|
|
212
|
+
if (Lc < 7.5) Lc = 0; // below perception floor
|
|
213
|
+
else Lc = Lc - 0.027 * 100;
|
|
214
|
+
} else {
|
|
215
|
+
// Light text on dark bg ("reverse polarity") → negative Lc
|
|
216
|
+
Lc = (Math.pow(Ybg, 0.65) - Math.pow(Yfg, 0.62)) * 1.14 * 100;
|
|
217
|
+
if (Lc > -7.5) Lc = 0;
|
|
218
|
+
else Lc = Lc + 0.027 * 100;
|
|
219
|
+
}
|
|
220
|
+
return Lc;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Convenience: choose a foreground (light or dark) that maximizes APCA
|
|
225
|
+
* contrast against the given background hex. Returns either '#000000' or
|
|
226
|
+
* '#ffffff'. Used by `<swatch-ui auto-contrast>`'s OKLab-L probe.
|
|
227
|
+
*/
|
|
228
|
+
export function pickContrastingFg(bgHex) {
|
|
229
|
+
const onDark = apcaContrast('#ffffff', bgHex);
|
|
230
|
+
const onLight = apcaContrast('#000000', bgHex);
|
|
231
|
+
return Math.abs(onDark) >= Math.abs(onLight) ? '#ffffff' : '#000000';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── LCH-space distance (perceptual distance) ──────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Compute perceptual distance between two OKLCH triples.
|
|
238
|
+
* Returns Euclidean distance in OKLab space — perceptually uniform.
|
|
239
|
+
* Useful for "closest swatch in a palette" lookups.
|
|
240
|
+
*/
|
|
241
|
+
export function oklchDistance(a, b) {
|
|
242
|
+
const aLab = oklchToOklab(a.L, a.C, a.H);
|
|
243
|
+
const bLab = oklchToOklab(b.L, b.C, b.H);
|
|
244
|
+
const dL = aLab.L - bLab.L;
|
|
245
|
+
const dA = aLab.a - bLab.a;
|
|
246
|
+
const dB = aLab.b - bLab.b;
|
|
247
|
+
return Math.sqrt(dL * dL + dA * dA + dB * dB);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Re-export constants ──────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export { MAX_CHROMA, GAMUT_EPSILON };
|
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#popover = null;
|
|
51
|
+
#button = null;
|
|
52
|
+
#picker = null;
|
|
53
|
+
#swatch = null;
|
|
54
|
+
#valueLabel = null;
|
|
55
|
+
#wired = false;
|
|
56
|
+
|
|
57
|
+
connected() {
|
|
58
|
+
this.#mount();
|
|
59
|
+
this.#wire();
|
|
60
|
+
this.#syncFromValue();
|
|
61
|
+
this.syncValue(this.value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render() {
|
|
65
|
+
this.#syncFromValue();
|
|
66
|
+
if (this.#popover && this.#popover.open !== this.open) {
|
|
67
|
+
this.#popover.open = this.open;
|
|
68
|
+
}
|
|
69
|
+
if (this.#picker && this.#picker.format !== this.format) {
|
|
70
|
+
this.#picker.format = this.format;
|
|
71
|
+
}
|
|
72
|
+
if (this.#button) {
|
|
73
|
+
if (this.disabled) this.#button.setAttribute('disabled', '');
|
|
74
|
+
else this.#button.removeAttribute('disabled');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Public API ──
|
|
79
|
+
|
|
80
|
+
showPicker() { this.open = true; }
|
|
81
|
+
hidePicker() { this.open = false; }
|
|
82
|
+
|
|
83
|
+
// ── Internal ──
|
|
84
|
+
|
|
85
|
+
#mount() {
|
|
86
|
+
if (this.firstElementChild) {
|
|
87
|
+
this.#popover = this.querySelector(':scope > popover-ui');
|
|
88
|
+
this.#button = this.querySelector(':scope > popover-ui > button-ui[slot="trigger"]');
|
|
89
|
+
this.#picker = this.querySelector(':scope > popover-ui > color-picker-ui[slot="content"]');
|
|
90
|
+
this.#swatch = this.querySelector('.color-input__swatch');
|
|
91
|
+
this.#valueLabel = this.querySelector('.color-input__value');
|
|
92
|
+
if (this.#popover && this.#button && this.#picker) return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const popover = document.createElement('popover-ui');
|
|
96
|
+
popover.setAttribute('trigger', 'click');
|
|
97
|
+
popover.setAttribute('placement', this.placement);
|
|
98
|
+
|
|
99
|
+
const button = document.createElement('button-ui');
|
|
100
|
+
button.setAttribute('slot', 'trigger');
|
|
101
|
+
button.setAttribute('variant', 'outline');
|
|
102
|
+
button.setAttribute('aria-haspopup', 'dialog');
|
|
103
|
+
button.setAttribute('aria-label', this.getAttribute('aria-label') || 'Pick a color');
|
|
104
|
+
|
|
105
|
+
const swatch = document.createElement('span');
|
|
106
|
+
swatch.className = 'color-input__swatch';
|
|
107
|
+
swatch.setAttribute('aria-hidden', 'true');
|
|
108
|
+
|
|
109
|
+
const valueLabel = document.createElement('span');
|
|
110
|
+
valueLabel.className = 'color-input__value';
|
|
111
|
+
|
|
112
|
+
button.append(swatch, valueLabel);
|
|
113
|
+
|
|
114
|
+
const picker = document.createElement('color-picker-ui');
|
|
115
|
+
picker.setAttribute('slot', 'content');
|
|
116
|
+
picker.setAttribute('format', this.format);
|
|
117
|
+
picker.setAttribute('value', this.value);
|
|
118
|
+
|
|
119
|
+
popover.append(button, picker);
|
|
120
|
+
this.append(popover);
|
|
121
|
+
|
|
122
|
+
this.#popover = popover;
|
|
123
|
+
this.#button = button;
|
|
124
|
+
this.#picker = picker;
|
|
125
|
+
this.#swatch = swatch;
|
|
126
|
+
this.#valueLabel = valueLabel;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#wire() {
|
|
130
|
+
if (this.#wired) return;
|
|
131
|
+
this.#wired = true;
|
|
132
|
+
|
|
133
|
+
this.#picker.addEventListener('change', this.#onPickerChange);
|
|
134
|
+
this.#picker.addEventListener('input', this.#onPickerInput);
|
|
135
|
+
|
|
136
|
+
// Track popover open/close so the host's `open` prop reflects reality
|
|
137
|
+
// even when the user dismisses via ESC / outside-click.
|
|
138
|
+
const sync = () => {
|
|
139
|
+
const open = !!this.#popover?.open;
|
|
140
|
+
if (this.open !== open) this.open = open;
|
|
141
|
+
};
|
|
142
|
+
this.#popover.addEventListener('toggle', sync);
|
|
143
|
+
new MutationObserver(sync).observe(this.#popover, {
|
|
144
|
+
attributes: true,
|
|
145
|
+
attributeFilter: ['open'],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#onPickerChange = (e) => {
|
|
150
|
+
this.#commit(e.detail, 'change');
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
#onPickerInput = (e) => {
|
|
154
|
+
this.#commit(e.detail, 'input');
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
#commit(detail, kind) {
|
|
158
|
+
if (!detail) return;
|
|
159
|
+
const next = this.format === 'oklch' ? detail.oklch : detail.hex;
|
|
160
|
+
if (next && next !== this.value) {
|
|
161
|
+
this.value = next;
|
|
162
|
+
this.syncValue(next);
|
|
163
|
+
}
|
|
164
|
+
this.#syncFromValue();
|
|
165
|
+
this.dispatchEvent(new CustomEvent(kind, {
|
|
166
|
+
bubbles: true,
|
|
167
|
+
composed: true,
|
|
168
|
+
detail: { value: next ?? this.value, hex: detail.hex, oklch: detail.oklch },
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#syncFromValue() {
|
|
173
|
+
if (this.#swatch) {
|
|
174
|
+
this.#swatch.style.setProperty('--swatch-color', this.value);
|
|
175
|
+
}
|
|
176
|
+
if (this.#valueLabel) {
|
|
177
|
+
this.#valueLabel.textContent = this.value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
disconnected() {
|
|
182
|
+
this.#picker?.removeEventListener('change', this.#onPickerChange);
|
|
183
|
+
this.#picker?.removeEventListener('input', this.#onPickerInput);
|
|
184
|
+
this.#wired = false;
|
|
185
|
+
}
|
|
186
|
+
}
|