tweakit 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/AGENTS.md +32 -30
  2. package/README.md +55 -29
  3. package/THIRD-PARTY-NOTICES.md +0 -1
  4. package/dist/tweaks/auto.js +2 -0
  5. package/dist/tweaks/auto.js.map +6 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  7. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  9. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  11. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  13. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  14. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  15. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  16. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  17. package/dist/tweaks/color-UGYMAF3W.js.map +6 -0
  18. package/dist/tweaks/core.js +2 -1
  19. package/dist/tweaks/core.js.map +6 -0
  20. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  21. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  22. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  23. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  24. package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
  25. package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
  26. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  27. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  28. package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
  29. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  30. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  31. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  32. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  33. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  34. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  35. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  36. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  37. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  38. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  39. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  40. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  41. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  42. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  43. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  44. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  45. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  46. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  47. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  48. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  49. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  50. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  51. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  52. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  53. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  54. package/dist/tweaks.css +1 -1
  55. package/dist/tweaks.js +7 -3
  56. package/dist/tweaks.js.map +6 -0
  57. package/dist/types/tweaks/easing.d.ts +8 -11
  58. package/dist/types/tweaks/enhance.d.ts +3 -2
  59. package/dist/types/tweaks/panel.d.ts +6 -4
  60. package/dist/types/tweaks/schema.d.ts +4 -8
  61. package/dist/types/tweaks/standalone.d.ts +3 -3
  62. package/dist/types/tweaks/types.d.ts +499 -116
  63. package/package.json +30 -5
  64. package/src/tweaks/auto.ts +16 -0
  65. package/src/tweaks/clip.ts +156 -0
  66. package/src/tweaks/controls/basic.ts +727 -0
  67. package/src/tweaks/controls/bezier.ts +204 -0
  68. package/src/tweaks/controls/color.ts +861 -0
  69. package/src/tweaks/controls/gradient.ts +419 -0
  70. package/src/tweaks/controls/image.ts +555 -0
  71. package/src/tweaks/controls/interval.ts +189 -0
  72. package/src/tweaks/controls/monitor.ts +211 -0
  73. package/src/tweaks/controls/motion.ts +316 -0
  74. package/src/tweaks/controls/plot.ts +440 -0
  75. package/src/tweaks/controls/point.ts +208 -0
  76. package/src/tweaks/controls/shadow.ts +156 -0
  77. package/src/tweaks/controls/sides.ts +196 -0
  78. package/src/tweaks/controls/spring.ts +308 -0
  79. package/src/tweaks/controls/tabs.ts +68 -0
  80. package/src/tweaks/core.ts +9 -0
  81. package/src/tweaks/easing.ts +118 -0
  82. package/src/tweaks/enhance.ts +35 -0
  83. package/src/tweaks/feedback.ts +218 -0
  84. package/src/tweaks/fold.ts +33 -0
  85. package/src/tweaks/heavy.ts +626 -0
  86. package/src/tweaks/icons.ts +26 -0
  87. package/src/tweaks/image-store.ts +189 -0
  88. package/src/tweaks/lazy.ts +63 -0
  89. package/src/tweaks/markup.ts +317 -0
  90. package/src/tweaks/panel.ts +1693 -0
  91. package/src/tweaks/presets.ts +85 -0
  92. package/src/tweaks/schema.ts +351 -0
  93. package/src/tweaks/shadow-root.ts +178 -0
  94. package/src/tweaks/shared.ts +1137 -0
  95. package/src/tweaks/single.ts +22 -0
  96. package/src/tweaks/standalone.ts +179 -0
  97. package/src/tweaks/types.ts +1027 -0
  98. package/src/tweaks/undo.ts +104 -0
  99. package/src/tweaks.css +965 -0
  100. package/src/wide-gamut.ts +658 -0
  101. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  102. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  103. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  104. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  105. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  106. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  107. package/dist/tweaks/fold-UGN7TXGX.js +0 -1
  108. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  109. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  110. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  111. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  112. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  113. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  114. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  115. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  116. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  117. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  118. package/dist/tweaks/sides-FH227Z54.js +0 -1
  119. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  120. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,861 @@
1
+ // ── Color — wide-gamut OKLCH picker. Lazy: dynamic-imported on first use, and
2
+ // the only module that loads wide-gamut.js (so basic panels never pay for it).
3
+ import { el, txt, clamp, rangeStep, popover, quietFocus, activeIn, registerControl, words, using } from "../shared.js";
4
+ import {
5
+ escapeReverts,
6
+ numField,
7
+ grabSurface,
8
+ boxFrac,
9
+ arrowStep,
10
+ sayWhenKeysStop,
11
+ triggerRow,
12
+ selectAllOnFocus,
13
+ } from "../heavy.js";
14
+ import {
15
+ oklchGamutProbe,
16
+ chromaCeil,
17
+ toGamut,
18
+ hexByte,
19
+ hexToOklch,
20
+ channelValues,
21
+ withChannel,
22
+ gamutLabel,
23
+ showsGamutBoundary,
24
+ readout,
25
+ serialize,
26
+ EDIT_MODES,
27
+ MODE_LABELS,
28
+ MODE_CHANNELS,
29
+ MAX_CHROMA,
30
+ convert,
31
+ oklchToRgbFn,
32
+ num,
33
+ } from "../../wide-gamut.js";
34
+ import type { Vec3, Space, Gamut } from "../../wide-gamut.js";
35
+ import type { OnChange, NumField } from "../shared.js";
36
+ import type { Meta } from "../schema.js";
37
+ import type { Control, ColorMode } from "../types.js";
38
+
39
+ /** A parsed color: OKLCH plus alpha. */
40
+ export type Oklcha = [number, number, number, number];
41
+ /** What the picker body is built from: the opening color, the notation, and an onMode hook for the host's trigger. */
42
+ export interface PickerOptions {
43
+ value?: string;
44
+ mode?: ColorMode;
45
+ onMode?: () => void;
46
+ }
47
+ /** The picker body's handle — a Control plus the mode and trigger-painting surface. */
48
+ export interface PickerBody extends Control {
49
+ el: HTMLDivElement;
50
+ set(v: string): void;
51
+ get(): string;
52
+ mode(): ColorMode;
53
+ setMode(m: ColorMode): void;
54
+ reflow(): void;
55
+ swatchCss(): string;
56
+ valueText(): string;
57
+ }
58
+
59
+ // ── Color-area rendering. The plane stretches to the P3 gamut, drawn at device-pixel
60
+ // resolution in a Display-P3 canvas where supported, so wide colors render true. ──
61
+ const WIDE_CANVAS = (() => {
62
+ try {
63
+ return (
64
+ document.createElement("canvas").getContext("2d", { colorSpace: "display-p3" })?.getContextAttributes?.()
65
+ .colorSpace === "display-p3"
66
+ );
67
+ } catch {
68
+ return false;
69
+ }
70
+ })();
71
+ const CANVAS_CS: CanvasRenderingContext2DSettings & ImageDataSettings = WIDE_CANVAS ? { colorSpace: "display-p3" } : {};
72
+ const ENGINE_GAMUT: Gamut = WIDE_CANVAS ? "p3" : "srgb"; // working gamut for the rasterized pixels
73
+ const sampleCurve = (curve: Float64Array, t: number) => {
74
+ const last = curve.length - 1,
75
+ pos = Math.max(0, Math.min(last, t * last)),
76
+ i = Math.floor(pos),
77
+ f = pos - i;
78
+ return curve[i] * (1 - f) + curve[Math.min(i + 1, last)] * f;
79
+ };
80
+ const gamutCurve = (hue: number, gamut: Gamut) => {
81
+ const probe = oklchGamutProbe(hue, gamut),
82
+ c = new Float64Array(128);
83
+ for (let i = 0; i < 128; i++) c[i] = chromaCeil(probe, i / 127);
84
+ return c;
85
+ };
86
+ const CHECKER = "var(--twk-checker)"; // the alpha checker, a tweaks.css token
87
+
88
+ // Parsing: any CSS color (hex / oklch / oklab / lab / lch / color() / rgb / hsl / named) → [L, C, H, alpha].
89
+ // The CSS Color 4 functions are parsed by regex + the engine; the canvas fallback is only safe
90
+ // for sRGB-family colors, since its fillStyle getter echoes wide colors in their own notation.
91
+ // Null-prototype: the space name comes from the parsed string, so `color(constructor …)` must
92
+ // miss rather than resolve to a member of Object.prototype and reach convert().
93
+ const COLOR_FN_SPACES: Record<string, Space> = Object.assign(Object.create(null), {
94
+ srgb: "srgb",
95
+ "display-p3": "p3",
96
+ rec2020: "rec2020",
97
+ "prophoto-rgb": "prophoto-rgb",
98
+ "a98-rgb": "a98-rgb",
99
+ "srgb-linear": "srgb-linear",
100
+ xyz: "xyz-d65",
101
+ "xyz-d65": "xyz-d65",
102
+ "xyz-d50": "xyz-d50",
103
+ });
104
+ // sRGB-family functions; no nested parens
105
+ const SRGB_FN_RE = /^(rgba?|hsla?|hwb)\([ \t\n\r\f]*([^)]*?)[ \t\n\r\f]*\)$/i;
106
+ const ENGINE_FN_RE = /^(oklch|oklab|lch|lab|color)\([ \t\n\r\f]*([^)]*?)[ \t\n\r\f]*\)$/i; // the engine's own functions
107
+ // color()'s predefined spaces
108
+ const CSS_SPACES = /^(srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65)$/i;
109
+ const HEX_RE = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; // 3/4/6/8 digits only; 5/7 is junk
110
+ // The one scratch 2D context; null where the DOM has no canvas (jsdom), and that is cached too.
111
+ let c2dCache: CanvasRenderingContext2D | null;
112
+ const canvas2d = () =>
113
+ c2dCache === undefined ? (c2dCache = document.createElement("canvas").getContext("2d")) : c2dCache;
114
+ // Shape first, then grammar: a stop's text is concatenated into a `linear-gradient()`, where a
115
+ // stray ")" could splice in a url(), and a color-shaped `oklch(a b c)` makes a browser drop the
116
+ // whole gradient. A bare keyword is put to the canvas against two sentinels, so a legitimate
117
+ // `black` isn't mistaken for the "junk leaves fillStyle unchanged" echo.
118
+ const COLOR_TOKEN = /^(#[0-9a-f]{3,8}|[a-z][a-z-]*(?:\([\w\s.,%+\-\/]*\))?)$/i;
119
+ /** Is `str` one CSS color token and nothing more? The gate a gradient stop passes before it reaches a style sink. */
120
+ // CSS's named colors, for a page with no canvas to ask: without them any word passed as a color.
121
+ const NAMED = new Set(
122
+ "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen transparent currentcolor".split(
123
+ " ",
124
+ ),
125
+ );
126
+ export function isColor(str: string | null | undefined) {
127
+ str = String(str == null ? "" : str).trim();
128
+ if (!COLOR_TOKEN.test(str)) return false;
129
+ if (HEX_RE.test(str)) return true;
130
+ if (ENGINE_FN_RE.test(str)) return engineFn(str);
131
+ if (SRGB_FN_RE.test(str)) return srgbFn(str) !== null;
132
+ // no canvas (jsdom, a worker): only CSS's own names, so "junk" isn't read as black
133
+ const c2d = canvas2d();
134
+ if (!c2d) return !str.includes("(") && NAMED.has(str.toLowerCase());
135
+ return ["#000", "#fff"].some((sentinel) => {
136
+ c2d.fillStyle = sentinel;
137
+ const before = c2d.fillStyle;
138
+ c2d.fillStyle = str;
139
+ return c2d.fillStyle !== before;
140
+ });
141
+ }
142
+ // The sRGB-family functions by CSS Color 4's own grammar — modern and legacy syntax, `none`,
143
+ // percentages, angle units — and nothing looser, so a color-shaped string CSS would reject
144
+ // (`rgb(a b c)`, a mixed legacy `rgb(255, 0%, 0)`) is null here. Channels clamp where CSS clamps.
145
+ const CSS_NUM = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:e[+-]?\\d+)?"; // CSS's <number>; a trailing dot ("1.") is not one
146
+ // keywords are ASCII case-insensitive
147
+ const TOK_NUM = new RegExp("^" + CSS_NUM + "$", "i"),
148
+ TOK_PCT = new RegExp("^" + CSS_NUM + "%$", "i"),
149
+ TOK_ANGLE = new RegExp("^(" + CSS_NUM + ")(deg|grad|rad|turn)$", "i"),
150
+ CSS_WS = /[ \t\n\r\f]+/,
151
+ NONE = /^none$/i;
152
+ const cssTrim = (t: string) => t.replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, "");
153
+ const one = (t: string) => !!t && !CSS_WS.test(t); // exactly one token in the slot
154
+ const tokKind = (t: string) =>
155
+ NONE.test(t) ? "none" : TOK_NUM.test(t) ? "num" : TOK_PCT.test(t) ? "pct" : TOK_ANGLE.test(t) ? "angle" : null;
156
+ // num() on both paths: Infinity % 360 is NaN, which would zero every channel.
157
+ const parseAngle = (t: string) => {
158
+ const m = TOK_ANGLE.exec(t);
159
+ if (!m) return num(parseFloat(t));
160
+ const n = parseFloat(m[1]),
161
+ u = m[2].toLowerCase();
162
+ return num(u === "turn" ? n * 360 : u === "grad" ? n * 0.9 : u === "rad" ? (n * 180) / Math.PI : n);
163
+ };
164
+ function srgbFn(str: string): [Vec3, number] | null {
165
+ const m = SRGB_FN_RE.exec(str);
166
+ if (!m) return null;
167
+ const fn = m[1].toLowerCase().replace(/a$/, "") as "rgb" | "hsl" | "hwb",
168
+ body = m[2];
169
+ let ch: string[], alpha: string | undefined, kinds: Array<ReturnType<typeof tokKind>>;
170
+ if (body.includes(",")) {
171
+ // legacy: rgb()/hsl() only, no none, no mixed units
172
+ if (fn === "hwb") return null;
173
+ const parts = body.split(",").map(cssTrim);
174
+ if (parts.length < 3 || parts.length > 4 || !parts.every(one)) return null;
175
+ ch = parts.slice(0, 3);
176
+ alpha = parts[3];
177
+ kinds = ch.map(tokKind);
178
+ if (
179
+ fn === "rgb"
180
+ ? !(kinds.every((k) => k === "num") || kinds.every((k) => k === "pct"))
181
+ : !((kinds[0] === "num" || kinds[0] === "angle") && kinds[1] === "pct" && kinds[2] === "pct")
182
+ )
183
+ return null;
184
+ if (alpha !== undefined && tokKind(alpha) !== "num" && tokKind(alpha) !== "pct") return null;
185
+ } else {
186
+ const slash = body.split("/");
187
+ if (slash.length > 2) return null;
188
+ ch = slash[0].split(CSS_WS).filter(Boolean);
189
+ if (ch.length !== 3) return null;
190
+ if (slash.length === 2) {
191
+ alpha = cssTrim(slash[1]);
192
+ const ak = one(alpha) ? tokKind(alpha) : null;
193
+ if (!ak || ak === "angle") return null;
194
+ }
195
+ kinds = ch.map(tokKind);
196
+ if (
197
+ fn === "rgb"
198
+ ? !kinds.every((k) => k && k !== "angle")
199
+ : !(kinds[0] && kinds[0] !== "pct" && kinds[1] && kinds[1] !== "angle" && kinds[2] && kinds[2] !== "angle")
200
+ )
201
+ return null;
202
+ }
203
+ // never NaN; ±Infinity meets the clamp below
204
+ const n = (t: string, max: number) => (NONE.test(t) ? 0 : /%$/.test(t) ? (parseFloat(t) / 100) * max : parseFloat(t));
205
+ const A = alpha === undefined ? 1 : clamp(n(alpha, 1), 0, 1);
206
+ const k =
207
+ fn === "rgb"
208
+ ? convert(
209
+ [clamp(n(ch[0], 255) / 255, 0, 1), clamp(n(ch[1], 255) / 255, 0, 1), clamp(n(ch[2], 255) / 255, 0, 1)],
210
+ "srgb",
211
+ "oklch",
212
+ )
213
+ : convert(
214
+ [kinds[0] === "none" ? 0 : parseAngle(ch[0]), clamp(n(ch[1], 100), 0, 100), clamp(n(ch[2], 100), 0, 100)],
215
+ fn,
216
+ "oklch",
217
+ );
218
+ return [k, A];
219
+ }
220
+ // The engine's own functions by the same standard: three channel tokens (the hue slot of
221
+ // lch()/oklch() takes an angle, not a percentage), an optional `/ alpha`, and for color() a predefined space first.
222
+ const engineFn = (str: string) => {
223
+ const m = ENGINE_FN_RE.exec(str);
224
+ if (!m) return false;
225
+ const fn = m[1].toLowerCase(),
226
+ slash = m[2].split("/");
227
+ if (slash.length > 2) return false;
228
+ const toks = slash[0].split(CSS_WS).filter(Boolean);
229
+ if (fn === "color" && !CSS_SPACES.test(toks.shift() || "")) return false;
230
+ if (toks.length !== 3) return false;
231
+ const hue = fn === "lch" || fn === "oklch";
232
+ if (
233
+ !toks.every((t, i) => {
234
+ const k = tokKind(t);
235
+ return k && (hue && i === 2 ? k !== "pct" : k !== "angle");
236
+ })
237
+ )
238
+ return false;
239
+ if (slash.length === 2) {
240
+ const a = cssTrim(slash[1]),
241
+ ak = one(a) ? tokKind(a) : null;
242
+ if (!ak || ak === "angle") return false;
243
+ }
244
+ return true;
245
+ };
246
+ // A parsed color as the picker holds it: no NaN, chroma capped at MAX_CHROMA (`oklch(0.5 1e999 0)` parsed to C =
247
+ // Infinity and hung the gamut map's bisection), and the hue in [0,360) so a negative input can't strand the thumb.
248
+ const held = (k: Vec3, A: number): Oklcha => [
249
+ num(k[0]),
250
+ clamp(num(k[1]), 0, MAX_CHROMA),
251
+ ((num(k[2]) % 360) + 360) % 360,
252
+ A,
253
+ ];
254
+ function parseColor(str: string | null | undefined): Oklcha {
255
+ str = String(str == null ? "" : str).trim();
256
+ if (HEX_RE.test(str)) {
257
+ let hx = str.replace("#", ""),
258
+ A = 1;
259
+ if (hx.length === 4) {
260
+ A = parseInt(hx[3] + hx[3], 16) / 255;
261
+ hx = hx.slice(0, 3);
262
+ } else if (hx.length === 8) {
263
+ A = parseInt(hx.slice(6, 8), 16) / 255;
264
+ hx = hx.slice(0, 6);
265
+ }
266
+ const [L, C, H] = hexToOklch("#" + hx);
267
+ return [L, C, H, A];
268
+ }
269
+ const m = ENGINE_FN_RE.exec(str);
270
+ if (m) {
271
+ const fn = m[1].toLowerCase();
272
+ const [body, aRaw] = m[2].split("/").map((s) => s.trim());
273
+ let toks = body.split(/\s+/).filter(Boolean),
274
+ space = fn as Space;
275
+ // an unsupported color() space degrades to the default
276
+ if (fn === "color") {
277
+ space = COLOR_FN_SPACES[(toks[0] || "").toLowerCase()];
278
+ toks = toks.slice(1);
279
+ if (!space) return [0.7, 0.1, 280, 1];
280
+ }
281
+ // One channel: `none` → 0, `%` scales by the slot's ratio, num() keeps NaN out (a seed degrades, it doesn't
282
+ // reject).
283
+ const ch = (t: string | undefined, pctScale = 1) =>
284
+ !t || NONE.test(t) ? 0 : num(parseFloat(t)) * (/%$/.test(t) ? pctScale : 1);
285
+ const A = aRaw ? clamp(ch(aRaw, 0.01), 0, 1) : 1;
286
+ // L and chroma clamp at parse time, as CSS Color 4 does.
287
+ const c: Vec3 =
288
+ // L% is 0–100 as-is; a/b 100% ↔ ±125
289
+ fn === "lab"
290
+ ? [clamp(ch(toks[0]), 0, 100), ch(toks[1], 1.25), ch(toks[2], 1.25)]
291
+ : // C 100% ↔ 150; H takes angle units
292
+ fn === "lch"
293
+ ? [clamp(ch(toks[0]), 0, 100), Math.max(0, ch(toks[1], 1.5)), parseAngle(toks[2] || "0")]
294
+ : fn === "color"
295
+ ? [ch(toks[0], 0.01), ch(toks[1], 0.01), ch(toks[2], 0.01)]
296
+ : // L% → 0–1; a/b 100% ↔ ±0.4
297
+ fn === "oklab"
298
+ ? [clamp(ch(toks[0], 0.01), 0, 1), ch(toks[1], 0.004), ch(toks[2], 0.004)]
299
+ : // oklch
300
+ [clamp(ch(toks[0], 0.01), 0, 1), Math.max(0, ch(toks[1], 0.004)), parseAngle(toks[2] || "0")];
301
+ return held(space === "oklch" ? c : convert(c, space, "oklch"), A);
302
+ }
303
+ // rgb() / hsl() / hwb() by CSS's own grammar; a string it rejects parses as black like any junk.
304
+ if (SRGB_FN_RE.test(str)) {
305
+ const sf = srgbFn(str);
306
+ return sf ? held(...sf) : [0, 0, 0, 1];
307
+ }
308
+ // Named colors and `transparent` normalize via a canvas, whose fillStyle getter echoes
309
+ // "#rrggbb" or "rgba(r,g,b,a)". Junk, or no canvas at all (a headless DOM), parses as black.
310
+ const c2d = canvas2d();
311
+ if (!c2d) return [0, 0, 0, 1];
312
+ c2d.fillStyle = "#000";
313
+ c2d.fillStyle = str;
314
+ const norm = c2d.fillStyle as string; // a string was set, so the getter echoes a string
315
+ if (HEX_RE.test(norm)) return parseColor(norm); // gated on the hex shape, so the recursion is bounded
316
+ const rm = norm.match(/^rgba?\(([^)]*)\)/i);
317
+ const cm = ((rm ? rm[1] : "").match(/-?[\d.]+(?:e[+-]?\d+)?/gi) || [0, 0, 0]).map(Number);
318
+ const k = convert([cm[0] / 255, cm[1] / 255, cm[2] / 255], "srgb", "oklch");
319
+ return [num(k[0]), num(k[1]), num(k[2]), cm[3] != null ? clamp(num(cm[3]), 0, 1) : 1];
320
+ }
321
+
322
+ // Whether a written color is handed back as written: a color in a notation the picker edits, or in a color() space it
323
+ // reads but has no mode for (xyz, a98-rgb, prophoto-rgb…), with every number a plain one. Anything else (1e999, a named
324
+ // color) is normalized, as the picker reads it. Only a function's arguments are numbers: a hex's digits read as
325
+ // exponents (#1E40AF as 1e40) and `rec2020` as 2020, so those came back rewritten.
326
+ const verbatim = (v: unknown): v is string =>
327
+ typeof v === "string" &&
328
+ isColor(v) &&
329
+ (notationOf(v) !== null || /^\s*color\(/i.test(v)) &&
330
+ (HEX_RE.test(v.trim()) ||
331
+ (v.replace(/^\s*[a-z]+\(\s*(?:[a-z][\w-]*\s)?/i, "").match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []).every(
332
+ (n) => Math.abs(+n) <= 1000,
333
+ ));
334
+ // The edit mode a written color's notation maps to; null for one with no mode of its own (a named color, lab() with
335
+ // alpha in a form the picker can't echo), which opens in OKLCH.
336
+ const notationOf = (v: unknown): ColorMode | null => {
337
+ const t = String(v ?? "")
338
+ .trim()
339
+ .toLowerCase();
340
+ if (HEX_RE.test(t)) return "hex";
341
+ const fn = /^([a-z]+)\(\s*([a-z0-9-]*)/.exec(t);
342
+ if (!fn) return null;
343
+ if (fn[1] === "rgb" || fn[1] === "rgba") return t.includes(",") ? "css" : "srgb";
344
+ if (fn[1] === "color")
345
+ return fn[2] === "display-p3" ? "p3" : fn[2] === "rec2020" ? "rec2020" : fn[2] === "srgb" ? "srgb" : null;
346
+ return (
347
+ (["hsl", "hwb", "oklch", "oklab", "lch", "lab"] as const).find(
348
+ (m) => m === fn[1] || (fn[1] === "hsla" && m === "hsl"),
349
+ ) ?? null
350
+ );
351
+ };
352
+ // What a written color's notation says beyond its mode, kept through an edit until another mode is picked: the name
353
+ // (rgba, hsla), legacy commas and an alpha slot, color(srgb …) for rgb(). An edit wrote color(srgb 1 0 0) as rgb().
354
+ type Flavor = { name: string; legacy: boolean; alphaSlot: boolean };
355
+ const flavorOf = (v: string): Flavor | null => {
356
+ const m = /^\s*(rgba?|hsla?|color)\(\s*([^)]*)\)\s*$/i.exec(v);
357
+ if (!m) return null;
358
+ const name = m[1].toLowerCase(),
359
+ args = m[2].split(",").length;
360
+ if (name === "color") return /^srgb\s/i.test(m[2]) ? { name, legacy: false, alphaSlot: false } : null;
361
+ return { name, legacy: args > 1, alphaSlot: args === 4 };
362
+ };
363
+ // The dropdown's name for a kept flavor its mode doesn't write on its own ("color(srgb)", "rgba()", "hsl(,)" for
364
+ // commas), short as the mode names, since a select is as wide as its widest option; null when the mode writes it anyway
365
+ // (rgb() in RGB, rgba(…, 1) in CSS).
366
+ const keptLabel = (f: Flavor | null, mode: ColorMode) => {
367
+ if (!f) return null;
368
+ if (f.name === "color") return "color(srgb)";
369
+ const plain =
370
+ mode === "css"
371
+ ? { name: "rgba", legacy: true, alphaSlot: true }
372
+ : { name: mode === "hsl" ? "hsl" : "rgb", legacy: false, alphaSlot: false };
373
+ return f.name === plain.name && f.legacy === plain.legacy && f.alphaSlot === plain.alphaSlot
374
+ ? null
375
+ : `${f.name}(${f.legacy ? "," : ""})`;
376
+ };
377
+ // serialize()'s string in the written flavor, rebuilt from its numbers. Legacy syntax has no `none`: a gray's hue
378
+ // writes 0.
379
+ const flavored = (s: string, f: Flavor | null, oklch: Vec3, A: number) => {
380
+ if (!f) return s;
381
+ const alpha = A < 0.999 ? String(+A.toFixed(3)) : f.alphaSlot ? "1" : "";
382
+ // p3's precision
383
+ if (f.name === "color")
384
+ return `color(srgb ${toGamut(oklch, "srgb")
385
+ .map((x) => +clamp(num(x), 0, 1).toFixed(3) + 0)
386
+ .join(" ")}${alpha && ` / ${alpha}`})`;
387
+ const ch = (/\((.*)\)$/.exec(s)?.[1] ?? "").split(/\s*[,/]\s*|\s+/).slice(0, 3);
388
+ return f.legacy
389
+ ? `${f.name}(${ch.map((t) => (t === "none" ? "0" : t)).join(", ")}${alpha && `, ${alpha}`})`
390
+ : `${f.name}(${ch.join(" ")}${alpha && ` / ${alpha}`})`;
391
+ };
392
+
393
+ // ── Picker body ──
394
+ /** The editor surface: L×C plane, hue + alpha strips, mode dropdown + gamut label, and per-mode channel inputs.
395
+ * No trigger or popover of its own: the color control mounts one in its popover, the gradient editor re-points one at
396
+ * the selected stop. */
397
+ function createPickerBody(meta: PickerOptions, onChange: (c: string) => void): PickerBody {
398
+ const tr = words();
399
+ // the kit's default color, for none or junk (parseColor reads junk as black)
400
+ let [L, C, H, A] = parseColor(isColor(meta.value) ? meta.value : "#7c5cff");
401
+ // It opens in the notation the color was written in, unless the host seeds a mode, and hands back that exact string
402
+ // until it's edited: "#ff0000" read back as oklch(63% 0.26 29), so code expecting hex broke and pasted values
403
+ // drifted.
404
+ // a host's mode (the gradient's blend space) holds; only an unseeded picker follows a color's notation
405
+ const seeded = EDIT_MODES.includes(meta.mode);
406
+ let mode = seeded ? meta.mode : (notationOf(meta.value) ?? "oklch");
407
+ let raw: string | null = verbatim(meta.value) ? meta.value.trim() : null;
408
+ let flavor = !seeded && isColor(meta.value) ? flavorOf(String(meta.value)) : null; // until another mode is picked
409
+ let paintedHue = NaN,
410
+ chromaCurve: Float64Array | null = null,
411
+ chanFields: NumField[] = [];
412
+ let hueRingH = NaN,
413
+ hueRingBg = ""; // the hue ring depends only on H; cached
414
+ const colorStr = () => raw ?? flavored(serialize([L, C, H], mode, A), flavor, [L, C, H], A);
415
+
416
+ const root = el("div", "twk-color-body");
417
+ const area = el("div", "twk-wg-area");
418
+ const areaCanvas = document.createElement("canvas");
419
+ areaCanvas.className = "twk-wg-canvas";
420
+ const areaThumb = el("div", "twk-wg-thumb");
421
+ area.append(areaCanvas, areaThumb);
422
+ const hueBar = el("div", "twk-wg-hue");
423
+ const hueCanvas = document.createElement("canvas");
424
+ hueCanvas.className = "twk-wg-hue-canvas";
425
+ const hueThumb = el("div", "twk-wg-hue-thumb");
426
+ hueBar.append(hueCanvas, hueThumb);
427
+ const alphaBar = el("div", "twk-wg-alpha");
428
+ const alphaGrad = el("div", "twk-wg-alpha-grad");
429
+ const alphaThumb = el("div", "twk-wg-hue-thumb");
430
+ alphaBar.append(alphaGrad, alphaThumb);
431
+ // The rasters are decoration; the plane and strips carry the names and live values.
432
+ areaCanvas.setAttribute("aria-hidden", "true");
433
+ hueCanvas.setAttribute("aria-hidden", "true");
434
+ // Keyboard-operable plane: a focusable group (no ARIA role fits a 2D value; the readout rides on aria-description).
435
+ area.tabIndex = 0;
436
+ area.setAttribute("role", "group");
437
+ area.setAttribute("aria-label", tr("colorPlane", "Color plane: chroma across, lightness up"));
438
+ const modeRow = el("div", "twk-color-mode-row");
439
+ const modeSel = el("select", "twk-color-mode");
440
+ modeSel.setAttribute("aria-label", tr("colorMode", "Color mode"));
441
+ EDIT_MODES.forEach((m) => {
442
+ const o = document.createElement("option");
443
+ o.value = m;
444
+ o.textContent = MODE_LABELS[m];
445
+ modeSel.append(o);
446
+ });
447
+ // A kept flavor shows as its own option beside its mode's, selected, so picking the mode the dropdown would otherwise
448
+ // show is a change, and drops it: re-picking the shown option fires nothing, so rgb() couldn't come back from
449
+ // color(srgb).
450
+ const keptOpt = document.createElement("option");
451
+ keptOpt.value = "kept";
452
+ const showMode = () => {
453
+ const label = keptLabel(flavor, mode);
454
+ if (label) {
455
+ keptOpt.textContent = label;
456
+ modeSel.querySelector(`option[value="${mode}"]`)!.after(keptOpt);
457
+ modeSel.value = "kept";
458
+ } else {
459
+ keptOpt.remove();
460
+ modeSel.value = mode;
461
+ }
462
+ };
463
+ showMode();
464
+ const gamut2 = el("span", "twk-color-gamut");
465
+ modeRow.append(modeSel, gamut2);
466
+ const channels = el("div", "twk-color-channels");
467
+ root.append(area, hueBar, alphaBar, modeRow, channels);
468
+
469
+ const actx = areaCanvas.getContext("2d", CANVAS_CS);
470
+ const hctx = hueCanvas.getContext("2d");
471
+
472
+ // One offscreen raster per body: a hue drag repaints every move.
473
+ const off = document.createElement("canvas");
474
+ const octx = off.getContext("2d", CANVAS_CS);
475
+ let offData: ImageData | null = null;
476
+ const renderArea = () => {
477
+ // Layout size: getBoundingClientRect includes the pop's entrance scale.
478
+ const cssW = area.clientWidth,
479
+ cssH = area.clientHeight;
480
+ if (cssW < 2) return;
481
+ const dpr = window.devicePixelRatio || 1;
482
+ const stretch = showsGamutBoundary(mode) ? "p3" : "srgb";
483
+ const curve = gamutCurve(H, stretch);
484
+ chromaCurve = curve; // per-lightness chroma ceiling of the plane gamut
485
+ const backingW = Math.round(cssW * dpr),
486
+ backingH = Math.round(cssH * dpr);
487
+ // gradient rasterized at 1/4 res
488
+ const W = Math.max(1, Math.round(backingW / 4)),
489
+ Hh = Math.max(1, Math.round(backingH / 4));
490
+ if (off.width !== W || off.height !== Hh) {
491
+ off.width = W;
492
+ off.height = Hh;
493
+ offData = new ImageData(W, Hh, CANVAS_CS);
494
+ }
495
+ const data = offData.data;
496
+ const invH = Hh > 1 ? 1 / (Hh - 1) : 0,
497
+ invW = W > 1 ? 1 / (W - 1) : 0;
498
+ // H is constant for the raster, so fold the oklch→RGB chain once instead of a per-pixel convert().
499
+ const toRgb = oklchToRgbFn(H, ENGINE_GAMUT),
500
+ rgb = [0, 0, 0];
501
+ for (let y = 0; y < Hh; y++) {
502
+ const Lp = 1 - y * invH,
503
+ rowMax = sampleCurve(curve, Lp);
504
+ for (let x = 0; x < W; x++) {
505
+ toRgb(Lp, x * invW * rowMax, rgb);
506
+ const o = (y * W + x) * 4;
507
+ data[o] = Math.round(clamp(rgb[0], 0, 1) * 255);
508
+ data[o + 1] = Math.round(clamp(rgb[1], 0, 1) * 255);
509
+ data[o + 2] = Math.round(clamp(rgb[2], 0, 1) * 255);
510
+ data[o + 3] = 255;
511
+ }
512
+ }
513
+ octx.putImageData(offData, 0, 0);
514
+ areaCanvas.width = backingW;
515
+ areaCanvas.height = backingH;
516
+ actx.imageSmoothingEnabled = true;
517
+ actx.drawImage(off, 0, 0, backingW, backingH);
518
+ // Gamut boundaries: solid sRGB line inside, dashed P3 line on the displayable edge.
519
+ if (stretch !== "srgb") {
520
+ const trace = (gamut: Gamut, color: string, width: number, dash: number[]) => {
521
+ const probe = oklchGamutProbe(H, gamut),
522
+ pts: [number, number][] = [];
523
+ for (let s = 0; s <= 100; s++) {
524
+ const Lp = s / 100,
525
+ edge = sampleCurve(curve, Lp);
526
+ if (edge <= 0) continue;
527
+ const c = chromaCeil(probe, Lp, edge);
528
+ if (c <= 0) continue;
529
+ pts.push([Math.min((c / edge) * backingW, backingW - (width * dpr) / 2), (1 - Lp) * backingH]);
530
+ }
531
+ if (pts.length < 2) return;
532
+ actx.save();
533
+ actx.strokeStyle = color;
534
+ actx.lineWidth = width * dpr;
535
+ actx.lineJoin = actx.lineCap = "round";
536
+ actx.setLineDash(dash.map((d) => d * dpr));
537
+ actx.beginPath();
538
+ pts.forEach((p, i) => (i ? actx.lineTo(p[0], p[1]) : actx.moveTo(p[0], p[1])));
539
+ actx.stroke();
540
+ actx.restore();
541
+ };
542
+ trace("srgb", "rgba(255,255,255,0.7)", 1.5, []);
543
+ trace("p3", "rgba(255,255,255,0.4)", 1, [3, 3]);
544
+ }
545
+ paintedHue = H;
546
+ };
547
+ // The strip's color at a hue: the sRGB chroma ceiling at a fixed lightness, so every hue
548
+ // shows at its most saturated displayable form; the same fill paints the hue thumb's ring.
549
+ const STRIP_L = 0.7;
550
+ const vividHue = (hue: number) => {
551
+ const rgb = convert([STRIP_L, chromaCeil(oklchGamutProbe(hue, "srgb"), STRIP_L), hue], "oklch", "srgb");
552
+ return `rgb(${clamp(rgb[0] * 255, 0, 255) | 0},${clamp(rgb[1] * 255, 0, 255) | 0},${clamp(rgb[2] * 255, 0, 255) | 0})`;
553
+ };
554
+ let hueW = 0;
555
+ const renderHue = () => {
556
+ // layout size, as above
557
+ const w = Math.max(1, hueBar.clientWidth),
558
+ h = Math.max(1, hueBar.clientHeight);
559
+ if (w < 2) return;
560
+ if (w === hueW && hueCanvas.width === w) return; // the strip depends only on width
561
+ hueW = w;
562
+ hueCanvas.width = w;
563
+ hueCanvas.height = h;
564
+ for (let x = 0; x < w; x++) {
565
+ hctx.fillStyle = vividHue((x / (w - 1)) * 360);
566
+ hctx.fillRect(x, 0, 1, h);
567
+ }
568
+ };
569
+ const positionThumbs = () => {
570
+ const ceil = chromaCurve ? sampleCurve(chromaCurve, L) : 0.4; // thumb x is C as a fraction of the row's ceiling
571
+ // The area thumb may overhang, so a color at the gamut boundary reads as fully selected.
572
+ const at = (frac: number) => `${clamp(frac, 0, 1) * 100}%`;
573
+ // The strip thumbs stay inside their track: the center runs from +w/2 at 0 to −w/2 at 1.
574
+ const inside = (frac: number, w: number) => {
575
+ const f = clamp(frac, 0, 1);
576
+ return `calc(${f * 100}% + ${(0.5 - f) * w}px)`;
577
+ };
578
+ areaThumb.style.left = at(ceil > 0 ? C / ceil : 0);
579
+ areaThumb.style.top = at(1 - L);
580
+ hueThumb.style.left = inside(H / 360, 16);
581
+ alphaThumb.style.left = inside(A, 16);
582
+ alphaBar.setAttribute("aria-valuenow", String(+A.toFixed(2)));
583
+ // H may be 360: End reads as the top of the range
584
+ const hDeg = Math.round(H);
585
+ hueBar.setAttribute("aria-valuenow", String(hDeg));
586
+ hueBar.setAttribute("aria-valuetext", `${hDeg}°`);
587
+ area.setAttribute(
588
+ "aria-description",
589
+ tr("colorReadout", "lightness {l}%, chroma {c}", { l: Math.round(L * 100), c: +C.toFixed(3) }),
590
+ );
591
+ alphaGrad.style.background = `linear-gradient(to right, oklch(${L} ${C} ${H} / 0), oklch(${L} ${C} ${H}))`;
592
+ // Filled rings: a grabbed thumb scales past its track, so each ring carries its own
593
+ // color instead of revealing the dropdown bg in the slivers above and below the strip.
594
+ areaThumb.style.background = `oklch(${L} ${C} ${H})`;
595
+ alphaThumb.style.background = `linear-gradient(oklch(${L} ${C} ${H} / ${A}), oklch(${L} ${C} ${H} / ${A})), var(--twk-dropdown-bg)`;
596
+ if (H !== hueRingH) {
597
+ hueRingBg = vividHue(H);
598
+ hueRingH = H;
599
+ }
600
+ hueThumb.style.background = hueRingBg;
601
+ };
602
+ const hexText = () => serialize([L, C, H], "hex", A);
603
+ const refresh = () => {
604
+ // sRGB and P3 are names
605
+ const g = gamutLabel([L, C, H], mode);
606
+ gamut2.textContent = g === "wide" ? tr("wide", "wide") : g;
607
+ if (mode === "hex") {
608
+ const hx = channels.querySelector<HTMLInputElement>(".twk-color-chan-input");
609
+ if (hx && activeIn(hx) !== hx) hx.value = hexText();
610
+ } else {
611
+ const vals = channelValues([L, C, H], mode);
612
+ chanFields.forEach((f, i) => {
613
+ if (!f.el.contains(activeIn(f.el))) f.set(vals[i]);
614
+ });
615
+ }
616
+ };
617
+ // mounted at real size → rasterize + place the thumbs
618
+ const reflow = () => {
619
+ renderHue();
620
+ renderArea();
621
+ positionThumbs();
622
+ };
623
+ const emit = () => onChange(colorStr());
624
+ // renderArea bails on zero width, so an offscreen repaint is a cheap no-op.
625
+ const sync = (repaint: boolean) => {
626
+ if (repaint && H !== paintedHue) renderArea();
627
+ positionThumbs();
628
+ refresh();
629
+ };
630
+ // an edit writes the picker's own notation
631
+ const commit = (repaint: boolean) => {
632
+ raw = null;
633
+ sync(repaint);
634
+ emit();
635
+ };
636
+
637
+ const renderChannels = () => {
638
+ channels.replaceChildren();
639
+ chanFields = [];
640
+ if (mode === "hex") {
641
+ channels.classList.add("twk-color-channels--hex");
642
+ const wrap = el("div", "twk-color-chan");
643
+ // select all: you replace a color, not a digit
644
+ const inp = el("input", "twk-color-chan-input");
645
+ inp.type = "text";
646
+ inp.spellcheck = false;
647
+ inp.setAttribute("aria-label", tr("hex", "Hex color"));
648
+ quietFocus(inp);
649
+ selectAllOnFocus(inp);
650
+ inp.addEventListener("change", () => {
651
+ const v = inp.value.trim();
652
+ if (HEX_RE.test(v)) {
653
+ [L, C, H, A] = parseColor(v);
654
+ commit(true);
655
+ }
656
+ });
657
+ // refresh() skips a focused field; catch up on blur (change fires first, so a typed edit is in).
658
+ inp.addEventListener("blur", refresh);
659
+ inp.addEventListener("keydown", (e) => {
660
+ if (e.key === "Enter") inp.blur();
661
+ });
662
+ escapeReverts(inp, hexText);
663
+ wrap.append(txt("span", "twk-color-chan-label", "HEX"), inp);
664
+ channels.append(wrap);
665
+ } else {
666
+ // The same field as Point/Spring, so the grab handle works for color values too.
667
+ channels.classList.remove("twk-color-channels--hex");
668
+ const vals = channelValues([L, C, H], mode);
669
+ MODE_CHANNELS[mode].forEach((ch, i) => {
670
+ const f = numField({ label: ch.k, value: vals[i], min: ch.min, max: ch.max, step: ch.step }, (v) => {
671
+ [L, C, H] = withChannel([L, C, H], mode, i, clamp(v, ch.min, ch.max));
672
+ commit(true);
673
+ });
674
+ f.el.addEventListener("focusout", refresh); // as the hex field: catch up on the way out
675
+ chanFields.push(f);
676
+ channels.append(f.el);
677
+ });
678
+ }
679
+ refresh();
680
+ };
681
+
682
+ const ceilAt = (l: number) => (chromaCurve ? sampleCurve(chromaCurve, l) : 0.4);
683
+ const areaXY = (e: PointerEvent) => boxFrac(e, area);
684
+ const setArea = (e: PointerEvent) => {
685
+ const [fx, fy] = areaXY(e);
686
+ L = 1 - fy;
687
+ C = fx * ceilAt(L);
688
+ commit(false);
689
+ };
690
+ grabSurface(area, setArea);
691
+ // Arrows move the thumb, not the raw channels: chroma steps as a fraction of its row, so a
692
+ // vertical step stays in gamut. Home/End mean nothing for a 2D point and are swallowed so they don't scroll the page.
693
+ const say = sayWhenKeysStop(area);
694
+ area.addEventListener("keydown", (e) => {
695
+ if (e.key === "Home" || e.key === "End") {
696
+ e.preventDefault();
697
+ return;
698
+ }
699
+ const k = e.shiftKey ? 10 : 1,
700
+ [ax, ay] = arrowStep(e);
701
+ const dx = ax * k,
702
+ dy = ay ? ay * k : e.key === "PageUp" ? 10 : e.key === "PageDown" ? -10 : 0;
703
+ if (!dx && !dy) return;
704
+ e.preventDefault();
705
+ const c0 = ceilAt(L),
706
+ fx = clamp((c0 > 0 ? C / c0 : 0) + dx * 0.01, 0, 1);
707
+ L = clamp(L + dy * 0.01, 0, 1);
708
+ C = fx * ceilAt(L);
709
+ commit(false);
710
+ say();
711
+ });
712
+ // The hue and alpha strips: one slider each, from 0 to `max`, dragged across or stepped by the slider keys.
713
+ const strip = (
714
+ bar: HTMLElement,
715
+ label: string,
716
+ max: number,
717
+ step: number,
718
+ get: () => number,
719
+ put: (v: number) => void,
720
+ ) => {
721
+ bar.tabIndex = 0;
722
+ bar.setAttribute("role", "slider");
723
+ bar.setAttribute("aria-label", label);
724
+ bar.setAttribute("aria-valuemin", "0");
725
+ bar.setAttribute("aria-valuemax", String(max));
726
+ grabSurface(bar, (e) => put(boxFrac(e, bar)[0] * max));
727
+ bar.addEventListener("keydown", (e) => {
728
+ const nv = rangeStep(e, get(), step, 0, max);
729
+ if (nv == null) return;
730
+ e.preventDefault();
731
+ put(clamp(nv, 0, max));
732
+ });
733
+ };
734
+ strip(
735
+ hueBar,
736
+ tr("hue", "Hue"),
737
+ 360,
738
+ 1,
739
+ () => H,
740
+ (v) => {
741
+ H = v;
742
+ commit(true);
743
+ },
744
+ );
745
+ strip(
746
+ alphaBar,
747
+ tr("alpha", "Alpha"),
748
+ 1,
749
+ 0.01,
750
+ () => A,
751
+ (v) => {
752
+ A = v;
753
+ commit(false);
754
+ },
755
+ );
756
+
757
+ // A mode switch is formatting-only and never emits: notifying would push a no-op change into
758
+ // undo and rewrite a gradient's stored stop strings. The dropdown also repaints the host's trigger (meta.onMode);
759
+ // setMode is the host pushing a mode in, so it fires neither.
760
+ // The channel fields are rebuilt for the new mode, after the build: under the picker's own words again.
761
+ const remode = (m: ColorMode) => {
762
+ mode = m;
763
+ raw = null;
764
+ flavor = null;
765
+ showMode();
766
+ using(tr.s, renderChannels);
767
+ renderArea();
768
+ positionThumbs();
769
+ };
770
+ // the dropdown holds EDIT_MODES and the kept flavor
771
+ modeSel.addEventListener("change", () => {
772
+ if (modeSel.value === "kept") return;
773
+ remode(modeSel.value as ColorMode);
774
+ meta.onMode && meta.onMode();
775
+ });
776
+
777
+ renderChannels();
778
+
779
+ return {
780
+ el: root,
781
+ // Blur a focused text field before re-pointing: its change handler commits typed text against
782
+ // the OLD state, so stop-hopping can't land stop A's text on stop B. Only text fields: blurring the plane reads as
783
+ // focus leaving the popover, which closes it.
784
+ // remode() first: it clears raw
785
+ set: (v: string) => {
786
+ if (!isColor(v)) {
787
+ console.warn(`[tweaks] color: "${String(v).slice(0, 40)}" isn't a color — kept ${colorStr()}`);
788
+ return;
789
+ }
790
+ const ae = activeIn(root) as HTMLElement;
791
+ if (ae && root.contains(ae) && ae.matches("input")) ae.blur();
792
+ [L, C, H, A] = parseColor(v);
793
+ const m = seeded ? null : notationOf(v);
794
+ if (m && m !== mode) remode(m);
795
+ flavor = m ? flavorOf(v) : null;
796
+ showMode();
797
+ raw = verbatim(v) ? v.trim() : null;
798
+ sync(true);
799
+ },
800
+ get: () => colorStr(),
801
+ // The gradient reads the mode to choose its blend space and re-points it on a stored ramp.
802
+ mode: () => mode,
803
+ setMode: (m: ColorMode) => {
804
+ if (m !== mode && EDIT_MODES.includes(m)) remode(m);
805
+ },
806
+ reflow,
807
+ // The host paints its own trigger from these — the body carries no swatch/value of its own.
808
+ swatchCss: () => `linear-gradient(oklch(${L} ${C} ${H} / ${A}), oklch(${L} ${C} ${H} / ${A})), ${CHECKER}`,
809
+ valueText: () =>
810
+ readout([L, C, H], mode) + (A < 0.999 ? (mode === "hex" ? hexByte(A) : ` / ${+A.toFixed(2)}`) : ""),
811
+ };
812
+ }
813
+
814
+ // ── Color control — a trigger row (label + swatch + value readout) that opens a picker body in a popover. ──
815
+ function createColor(meta: Meta, onChange: OnChange): Control {
816
+ // ??: an explicit "" label renders none
817
+ const { root, trigger, right } = triggerRow("twk-color", meta.label ?? words()("color", "Color"));
818
+ const swatch = el("span", "twk-trigger-chip twk-color-swatch");
819
+ const valueEl = el("span", "twk-trigger-value");
820
+ right.append(swatch, valueEl);
821
+
822
+ const pop = el("div", "twk-color-pop");
823
+ const paintTrigger = () => {
824
+ swatch.style.background = body.swatchCss();
825
+ valueEl.textContent = body.valueText();
826
+ };
827
+ const body = createPickerBody(
828
+ { value: meta.value, mode: meta.mode as ColorMode, onMode: () => paintTrigger() },
829
+ (c) => {
830
+ paintTrigger();
831
+ onChange(c);
832
+ },
833
+ );
834
+ pop.append(body.el);
835
+ root.append(pop);
836
+
837
+ popover(root, trigger, pop, { fallbackH: 340, onOpen: body.reflow, onReflow: body.reflow });
838
+ paintTrigger();
839
+
840
+ return {
841
+ el: root,
842
+ set: (v: string) => {
843
+ if (v == null) return;
844
+ body.set(v);
845
+ paintTrigger();
846
+ },
847
+ get: () => body.get(),
848
+ };
849
+ }
850
+
851
+ /** An oklch() string at display precision, alpha appended only when translucent. */
852
+ const oklchStr = (L: number, C: number, H: number, A: number) =>
853
+ A < 0.999
854
+ ? `oklch(${+L.toFixed(4)} ${+C.toFixed(4)} ${+H.toFixed(2)} / ${+A.toFixed(3)})`
855
+ : `oklch(${+L.toFixed(4)} ${+C.toFixed(4)} ${+H.toFixed(2)})`;
856
+
857
+ export { createPickerBody, parseColor, oklchStr, CHECKER };
858
+ registerControl("color", createColor);
859
+ // Registered beside its control so createColorPicker() (standalone.ts) reaches it through the
860
+ // registry without a static import that would pull the color engine into the basic bundle.
861
+ registerControl("colorpicker", createPickerBody);