tweakit 0.6.0 → 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 (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -1,8 +1,36 @@
1
1
  // ── Color — wide-gamut OKLCH picker. Lazy: dynamic-imported on first use, and
2
2
  // the only module that loads wide-gamut.js (so basic panels never pay for it).
3
- import { el, txt, clamp, rangeStep, popover, quietFocus, registerControl } from "../shared.js";
4
- import { escapeReverts, numField, grabSurface, boxFrac, arrowStep, sayWhenKeysStop, triggerRow, selectAllOnFocus } from "../heavy.js";
5
- import { oklchGamutProbe, chromaCeil, toGamut, hexByte, hexToOklch, channelValues, withChannel, gamutLabel, showsGamutBoundary, readout, serialize, EDIT_MODES, MODE_LABELS, MODE_CHANNELS, MAX_CHROMA, convert, oklchToRgbFn, num } from "../../wide-gamut.js";
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";
6
34
  import type { Vec3, Space, Gamut } from "../../wide-gamut.js";
7
35
  import type { OnChange, NumField } from "../shared.js";
8
36
  import type { Meta } from "../schema.js";
@@ -11,32 +39,78 @@ import type { Control, ColorMode } from "../types.js";
11
39
  /** A parsed color: OKLCH plus alpha. */
12
40
  export type Oklcha = [number, number, number, number];
13
41
  /** What the picker body is built from: the opening color, the notation, and an onMode hook for the host's trigger. */
14
- export interface PickerOptions { value?: string; mode?: ColorMode; onMode?: () => void }
42
+ export interface PickerOptions {
43
+ value?: string;
44
+ mode?: ColorMode;
45
+ onMode?: () => void;
46
+ }
15
47
  /** The picker body's handle — a Control plus the mode and trigger-painting surface. */
16
- export interface PickerBody extends Control { el: HTMLDivElement; set(v: string): void; get(): string; mode(): ColorMode; setMode(m: ColorMode): void; reflow(): void; swatchCss(): string; valueText(): string }
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
+ }
17
58
 
18
59
  // ── Color-area rendering. The plane stretches to the P3 gamut, drawn at device-pixel
19
60
  // resolution in a Display-P3 canvas where supported, so wide colors render true. ──
20
- const WIDE_CANVAS = (() => { try { return document.createElement("canvas").getContext("2d", { colorSpace: "display-p3" })?.getContextAttributes?.().colorSpace === "display-p3"; } catch { return false; } })();
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
+ })();
21
71
  const CANVAS_CS: CanvasRenderingContext2DSettings & ImageDataSettings = WIDE_CANVAS ? { colorSpace: "display-p3" } : {};
22
72
  const ENGINE_GAMUT: Gamut = WIDE_CANVAS ? "p3" : "srgb"; // working gamut for the rasterized pixels
23
- const sampleCurve = (curve: Float64Array, t: number) => { const last = curve.length - 1, pos = Math.max(0, Math.min(last, t * last)), i = Math.floor(pos), f = pos - i; return curve[i] * (1 - f) + curve[Math.min(i + 1, last)] * f; };
24
- const gamutCurve = (hue: number, gamut: Gamut) => { const probe = oklchGamutProbe(hue, gamut), c = new Float64Array(128); for (let i = 0; i < 128; i++) c[i] = chromaCeil(probe, i / 127); return c; };
25
- const CHECKER = "var(--tw-checker)"; // the alpha checker, a tweaks.css token
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
26
87
 
27
88
  // Parsing: any CSS color (hex / oklch / oklab / lab / lch / color() / rgb / hsl / named) → [L, C, H, alpha].
28
89
  // The CSS Color 4 functions are parsed by regex + the engine; the canvas fallback is only safe
29
90
  // for sRGB-family colors, since its fillStyle getter echoes wide colors in their own notation.
30
91
  // Null-prototype: the space name comes from the parsed string, so `color(constructor …)` must
31
92
  // miss rather than resolve to a member of Object.prototype and reach convert().
32
- const COLOR_FN_SPACES: Record<string, Space> = Object.assign(Object.create(null), { srgb: "srgb", "display-p3": "p3", rec2020: "rec2020", "prophoto-rgb": "prophoto-rgb", "a98-rgb": "a98-rgb", "srgb-linear": "srgb-linear", xyz: "xyz-d65", "xyz-d65": "xyz-d65", "xyz-d50": "xyz-d50" });
33
- const SRGB_FN_RE = /^(rgba?|hsla?|hwb)\([ \t\n\r\f]*([^)]*?)[ \t\n\r\f]*\)$/i; // sRGB-family functions; no nested parens
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;
34
106
  const ENGINE_FN_RE = /^(oklch|oklab|lch|lab|color)\([ \t\n\r\f]*([^)]*?)[ \t\n\r\f]*\)$/i; // the engine's own functions
35
- const CSS_SPACES = /^(srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65)$/i; // color()'s predefined spaces
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;
36
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
37
110
  // The one scratch 2D context; null where the DOM has no canvas (jsdom), and that is cached too.
38
111
  let c2dCache: CanvasRenderingContext2D | null;
39
- const canvas2d = () => (c2dCache === undefined ? (c2dCache = document.createElement("canvas").getContext("2d")) : c2dCache);
112
+ const canvas2d = () =>
113
+ c2dCache === undefined ? (c2dCache = document.createElement("canvas").getContext("2d")) : c2dCache;
40
114
  // Shape first, then grammar: a stop's text is concatenated into a `linear-gradient()`, where a
41
115
  // stray ")" could splice in a url(), and a color-shaped `oklch(a b c)` makes a browser drop the
42
116
  // whole gradient. A bare keyword is put to the canvas against two sentinels, so a legitimate
@@ -44,99 +118,199 @@ const canvas2d = () => (c2dCache === undefined ? (c2dCache = document.createElem
44
118
  const COLOR_TOKEN = /^(#[0-9a-f]{3,8}|[a-z][a-z-]*(?:\([\w\s.,%+\-\/]*\))?)$/i;
45
119
  /** Is `str` one CSS color token and nothing more? The gate a gradient stop passes before it reaches a style sink. */
46
120
  // CSS's named colors, for a page with no canvas to ask: without them any word passed as a color.
47
- const NAMED = new Set("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(" "));
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
+ );
48
126
  export function isColor(str: string | null | undefined) {
49
127
  str = String(str == null ? "" : str).trim();
50
128
  if (!COLOR_TOKEN.test(str)) return false;
51
129
  if (HEX_RE.test(str)) return true;
52
130
  if (ENGINE_FN_RE.test(str)) return engineFn(str);
53
131
  if (SRGB_FN_RE.test(str)) return srgbFn(str) !== null;
54
- const c2d = canvas2d(); if (!c2d) return !str.includes("(") && NAMED.has(str.toLowerCase()); // no canvas (jsdom, a worker): only CSS's own names, so "junk" isn't read as black
55
- return ["#000", "#fff"].some((sentinel) => { c2d.fillStyle = sentinel; const before = c2d.fillStyle; c2d.fillStyle = str; return c2d.fillStyle !== before; });
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
+ });
56
141
  }
57
142
  // The sRGB-family functions by CSS Color 4's own grammar — modern and legacy syntax, `none`,
58
143
  // percentages, angle units — and nothing looser, so a color-shaped string CSS would reject
59
144
  // (`rgb(a b c)`, a mixed legacy `rgb(255, 0%, 0)`) is null here. Channels clamp where CSS clamps.
60
145
  const CSS_NUM = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:e[+-]?\\d+)?"; // CSS's <number>; a trailing dot ("1.") is not one
61
- const TOK_NUM = new RegExp("^" + CSS_NUM + "$", "i"), TOK_PCT = new RegExp("^" + CSS_NUM + "%$", "i"), TOK_ANGLE = new RegExp("^(" + CSS_NUM + ")(deg|grad|rad|turn)$", "i"), CSS_WS = /[ \t\n\r\f]+/, NONE = /^none$/i; // keywords are ASCII case-insensitive
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;
62
152
  const cssTrim = (t: string) => t.replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, "");
63
153
  const one = (t: string) => !!t && !CSS_WS.test(t); // exactly one token in the slot
64
- const tokKind = (t: string) => (NONE.test(t) ? "none" : TOK_NUM.test(t) ? "num" : TOK_PCT.test(t) ? "pct" : TOK_ANGLE.test(t) ? "angle" : null);
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;
65
156
  // num() on both paths: Infinity % 360 is NaN, which would zero every channel.
66
- const parseAngle = (t: string) => { const m = TOK_ANGLE.exec(t); if (!m) return num(parseFloat(t)); const n = parseFloat(m[1]), u = m[2].toLowerCase(); return num(u === "turn" ? n * 360 : u === "grad" ? n * 0.9 : u === "rad" ? (n * 180) / Math.PI : n); };
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
+ };
67
164
  function srgbFn(str: string): [Vec3, number] | null {
68
- const m = SRGB_FN_RE.exec(str); if (!m) return null;
69
- const fn = m[1].toLowerCase().replace(/a$/, "") as "rgb" | "hsl" | "hwb", body = m[2];
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];
70
169
  let ch: string[], alpha: string | undefined, kinds: Array<ReturnType<typeof tokKind>>;
71
- if (body.includes(",")) { // legacy: rgb()/hsl() only, no none, no mixed units
170
+ if (body.includes(",")) {
171
+ // legacy: rgb()/hsl() only, no none, no mixed units
72
172
  if (fn === "hwb") return null;
73
173
  const parts = body.split(",").map(cssTrim);
74
174
  if (parts.length < 3 || parts.length > 4 || !parts.every(one)) return null;
75
- ch = parts.slice(0, 3); alpha = parts[3]; kinds = ch.map(tokKind);
76
- if (fn === "rgb" ? !(kinds.every((k) => k === "num") || kinds.every((k) => k === "pct")) : !((kinds[0] === "num" || kinds[0] === "angle") && kinds[1] === "pct" && kinds[2] === "pct")) 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;
77
184
  if (alpha !== undefined && tokKind(alpha) !== "num" && tokKind(alpha) !== "pct") return null;
78
185
  } else {
79
- const slash = body.split("/"); if (slash.length > 2) return null;
80
- ch = slash[0].split(CSS_WS).filter(Boolean); if (ch.length !== 3) return null;
81
- if (slash.length === 2) { alpha = cssTrim(slash[1]); const ak = one(alpha) ? tokKind(alpha) : null; if (!ak || ak === "angle") return null; }
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
+ }
82
195
  kinds = ch.map(tokKind);
83
- if (fn === "rgb" ? !kinds.every((k) => k && k !== "angle") : !(kinds[0] && kinds[0] !== "pct" && kinds[1] && kinds[1] !== "angle" && kinds[2] && kinds[2] !== "angle")) return null;
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;
84
202
  }
85
- const n = (t: string, max: number) => (NONE.test(t) ? 0 : /%$/.test(t) ? (parseFloat(t) / 100) * max : parseFloat(t)); // never NaN; ±Infinity meets the clamp below
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));
86
205
  const A = alpha === undefined ? 1 : clamp(n(alpha, 1), 0, 1);
87
- const k = fn === "rgb"
88
- ? convert([clamp(n(ch[0], 255) / 255, 0, 1), clamp(n(ch[1], 255) / 255, 0, 1), clamp(n(ch[2], 255) / 255, 0, 1)], "srgb", "oklch")
89
- : convert([kinds[0] === "none" ? 0 : parseAngle(ch[0]), clamp(n(ch[1], 100), 0, 100), clamp(n(ch[2], 100), 0, 100)], fn, "oklch");
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
+ );
90
218
  return [k, A];
91
219
  }
92
220
  // The engine's own functions by the same standard: three channel tokens (the hue slot of
93
221
  // lch()/oklch() takes an angle, not a percentage), an optional `/ alpha`, and for color() a predefined space first.
94
222
  const engineFn = (str: string) => {
95
- const m = ENGINE_FN_RE.exec(str); if (!m) return false;
96
- const fn = m[1].toLowerCase(), slash = m[2].split("/"); if (slash.length > 2) return false;
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;
97
228
  const toks = slash[0].split(CSS_WS).filter(Boolean);
98
229
  if (fn === "color" && !CSS_SPACES.test(toks.shift() || "")) return false;
99
230
  if (toks.length !== 3) return false;
100
231
  const hue = fn === "lch" || fn === "oklch";
101
- if (!toks.every((t, i) => { const k = tokKind(t); return k && (hue && i === 2 ? k !== "pct" : k !== "angle"); })) return false;
102
- if (slash.length === 2) { const a = cssTrim(slash[1]), ak = one(a) ? tokKind(a) : null; if (!ak || ak === "angle") return false; }
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
+ }
103
244
  return true;
104
245
  };
105
- // A parsed color as the picker holds it: no NaN, chroma capped at MAX_CHROMA (`oklch(0.5 1e999 0)` parsed to C = Infinity
106
- // and hung the gamut map's bisection), and the hue in [0,360) so a negative input can't strand the thumb.
107
- const held = (k: Vec3, A: number): Oklcha => [num(k[0]), clamp(num(k[1]), 0, MAX_CHROMA), ((num(k[2]) % 360) + 360) % 360, A];
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
+ ];
108
254
  function parseColor(str: string | null | undefined): Oklcha {
109
255
  str = String(str == null ? "" : str).trim();
110
256
  if (HEX_RE.test(str)) {
111
- let hx = str.replace("#", ""), A = 1;
112
- if (hx.length === 4) { A = parseInt(hx[3] + hx[3], 16) / 255; hx = hx.slice(0, 3); }
113
- else if (hx.length === 8) { A = parseInt(hx.slice(6, 8), 16) / 255; hx = hx.slice(0, 6); }
114
- const [L, C, H] = hexToOklch("#" + hx); return [L, C, H, A];
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];
115
268
  }
116
269
  const m = ENGINE_FN_RE.exec(str);
117
270
  if (m) {
118
271
  const fn = m[1].toLowerCase();
119
272
  const [body, aRaw] = m[2].split("/").map((s) => s.trim());
120
- let toks = body.split(/\s+/).filter(Boolean), space = fn as Space;
121
- if (fn === "color") { space = COLOR_FN_SPACES[(toks[0] || "").toLowerCase()]; toks = toks.slice(1); if (!space) return [0.7, 0.1, 280, 1]; } // an unsupported color() space degrades to the default
122
- // One channel: `none` → 0, `%` scales by the slot's ratio, num() keeps NaN out (a seed degrades, it doesn't reject).
123
- const ch = (t: string | undefined, pctScale = 1) => (!t || NONE.test(t) ? 0 : num(parseFloat(t)) * (/%$/.test(t) ? pctScale : 1));
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);
124
285
  const A = aRaw ? clamp(ch(aRaw, 0.01), 0, 1) : 1;
125
286
  // L and chroma clamp at parse time, as CSS Color 4 does.
126
287
  const c: Vec3 =
127
- fn === "lab" ? [clamp(ch(toks[0]), 0, 100), ch(toks[1], 1.25), ch(toks[2], 1.25)] // L% is 0–100 as-is; a/b 100% ↔ ±125
128
- : fn === "lch" ? [clamp(ch(toks[0]), 0, 100), Math.max(0, ch(toks[1], 1.5)), parseAngle(toks[2] || "0")] // C 100% ↔ 150; H takes angle units
129
- : fn === "color" ? [ch(toks[0], 0.01), ch(toks[1], 0.01), ch(toks[2], 0.01)]
130
- : fn === "oklab" ? [clamp(ch(toks[0], 0.01), 0, 1), ch(toks[1], 0.004), ch(toks[2], 0.004)] // L% → 0–1; a/b 100% ↔ ±0.4
131
- : [clamp(ch(toks[0], 0.01), 0, 1), Math.max(0, ch(toks[1], 0.004)), parseAngle(toks[2] || "0")]; // oklch
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")];
132
301
  return held(space === "oklch" ? c : convert(c, space, "oklch"), A);
133
302
  }
134
303
  // rgb() / hsl() / hwb() by CSS's own grammar; a string it rejects parses as black like any junk.
135
- if (SRGB_FN_RE.test(str)) { const sf = srgbFn(str); return sf ? held(...sf) : [0, 0, 0, 1]; }
304
+ if (SRGB_FN_RE.test(str)) {
305
+ const sf = srgbFn(str);
306
+ return sf ? held(...sf) : [0, 0, 0, 1];
307
+ }
136
308
  // Named colors and `transparent` normalize via a canvas, whose fillStyle getter echoes
137
309
  // "#rrggbb" or "rgba(r,g,b,a)". Junk, or no canvas at all (a headless DOM), parses as black.
138
- const c2d = canvas2d(); if (!c2d) return [0, 0, 0, 1];
139
- c2d.fillStyle = "#000"; c2d.fillStyle = str;
310
+ const c2d = canvas2d();
311
+ if (!c2d) return [0, 0, 0, 1];
312
+ c2d.fillStyle = "#000";
313
+ c2d.fillStyle = str;
140
314
  const norm = c2d.fillStyle as string; // a string was set, so the getter echoes a string
141
315
  if (HEX_RE.test(norm)) return parseColor(norm); // gated on the hex shape, so the recursion is bounded
142
316
  const rm = norm.match(/^rgba?\(([^)]*)\)/i);
@@ -146,19 +320,34 @@ function parseColor(str: string | null | undefined): Oklcha {
146
320
  }
147
321
 
148
322
  // Whether a written color is handed back as written: a color in a notation the picker edits, or in a color() space it
149
- // reads but has no mode for (xyz, a98-rgb, prophoto-rgb…), with every number a plain one. Anything else (1e999, a named color) is normalized, as the picker reads it. Only a function's arguments are
150
- // numbers: a hex's digits read as exponents (#1E40AF as 1e40) and `rec2020` as 2020, so those came back rewritten.
151
- const verbatim = (v: unknown): v is string => typeof v === "string" && isColor(v) && (notationOf(v) !== null || /^\s*color\(/i.test(v)) && (HEX_RE.test(v.trim()) || (v.replace(/^\s*[a-z]+\(\s*(?:[a-z][\w-]*\s)?/i, "").match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) || []).every((n) => Math.abs(+n) <= 1000));
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
+ ));
152
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
153
335
  // alpha in a form the picker can't echo), which opens in OKLCH.
154
336
  const notationOf = (v: unknown): ColorMode | null => {
155
- const t = String(v ?? "").trim().toLowerCase();
337
+ const t = String(v ?? "")
338
+ .trim()
339
+ .toLowerCase();
156
340
  if (HEX_RE.test(t)) return "hex";
157
341
  const fn = /^([a-z]+)\(\s*([a-z0-9-]*)/.exec(t);
158
342
  if (!fn) return null;
159
343
  if (fn[1] === "rgb" || fn[1] === "rgba") return t.includes(",") ? "css" : "srgb";
160
- if (fn[1] === "color") return fn[2] === "display-p3" ? "p3" : fn[2] === "rec2020" ? "rec2020" : fn[2] === "srgb" ? "srgb" : null;
161
- return (["hsl", "hwb", "oklch", "oklab", "lch", "lab"] as const).find((m) => m === fn[1] || fn[1] === "hsla" && m === "hsl") ?? null;
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
+ );
162
351
  };
163
352
  // What a written color's notation says beyond its mode, kept through an edit until another mode is picked: the name
164
353
  // (rgba, hsla), legacy commas and an alpha slot, color(srgb …) for rgb(). An edit wrote color(srgb 1 0 0) as rgb().
@@ -166,66 +355,115 @@ type Flavor = { name: string; legacy: boolean; alphaSlot: boolean };
166
355
  const flavorOf = (v: string): Flavor | null => {
167
356
  const m = /^\s*(rgba?|hsla?|color)\(\s*([^)]*)\)\s*$/i.exec(v);
168
357
  if (!m) return null;
169
- const name = m[1].toLowerCase(), args = m[2].split(",").length;
358
+ const name = m[1].toLowerCase(),
359
+ args = m[2].split(",").length;
170
360
  if (name === "color") return /^srgb\s/i.test(m[2]) ? { name, legacy: false, alphaSlot: false } : null;
171
361
  return { name, legacy: args > 1, alphaSlot: args === 4 };
172
362
  };
173
- // The dropdown's name for a kept flavor its mode doesn't write on its own ("color(srgb)", "rgba()", "hsl(,)" for commas),
174
- // short as the mode names, since a select is as wide as its widest option; null when the mode writes it anyway (rgb() in
175
- // RGB, rgba(…, 1) in CSS).
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).
176
366
  const keptLabel = (f: Flavor | null, mode: ColorMode) => {
177
367
  if (!f) return null;
178
368
  if (f.name === "color") return "color(srgb)";
179
- const plain = mode === "css" ? { name: "rgba", legacy: true, alphaSlot: true } : { name: mode === "hsl" ? "hsl" : "rgb", legacy: false, alphaSlot: false };
180
- return f.name === plain.name && f.legacy === plain.legacy && f.alphaSlot === plain.alphaSlot ? null : `${f.name}(${f.legacy ? "," : ""})`;
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 ? "," : ""})`;
181
376
  };
182
- // serialize()'s string in the written flavor, rebuilt from its numbers. Legacy syntax has no `none`: a gray's hue writes 0.
377
+ // serialize()'s string in the written flavor, rebuilt from its numbers. Legacy syntax has no `none`: a gray's hue
378
+ // writes 0.
183
379
  const flavored = (s: string, f: Flavor | null, oklch: Vec3, A: number) => {
184
380
  if (!f) return s;
185
381
  const alpha = A < 0.999 ? String(+A.toFixed(3)) : f.alphaSlot ? "1" : "";
186
- if (f.name === "color") return `color(srgb ${toGamut(oklch, "srgb").map((x) => +clamp(num(x), 0, 1).toFixed(3) + 0).join(" ")}${alpha && ` / ${alpha}`})`; // p3's precision
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}`})`;
187
387
  const ch = (/\((.*)\)$/.exec(s)?.[1] ?? "").split(/\s*[,/]\s*|\s+/).slice(0, 3);
188
- return f.legacy ? `${f.name}(${ch.map((t) => (t === "none" ? "0" : t)).join(", ")}${alpha && `, ${alpha}`})` : `${f.name}(${ch.join(" ")}${alpha && ` / ${alpha}`})`;
388
+ return f.legacy
389
+ ? `${f.name}(${ch.map((t) => (t === "none" ? "0" : t)).join(", ")}${alpha && `, ${alpha}`})`
390
+ : `${f.name}(${ch.join(" ")}${alpha && ` / ${alpha}`})`;
189
391
  };
190
392
 
191
393
  // ── Picker body ──
192
394
  /** The editor surface: L×C plane, hue + alpha strips, mode dropdown + gamut label, and per-mode channel inputs.
193
- * No trigger or popover of its own: the color control mounts one in its popover, the gradient editor re-points one at the selected stop. */
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. */
194
397
  function createPickerBody(meta: PickerOptions, onChange: (c: string) => void): PickerBody {
195
- let [L, C, H, A] = parseColor(isColor(meta.value) ? meta.value : "#7c5cff"); // the kit's default color, for none or junk (parseColor reads junk as black)
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");
196
401
  // It opens in the notation the color was written in, unless the host seeds a mode, and hands back that exact string
197
- // until it's edited: "#ff0000" read back as oklch(63% 0.26 29), so code expecting hex broke and pasted values drifted.
198
- const seeded = EDIT_MODES.includes(meta.mode); // a host's mode (the gradient's blend space) holds; only an unseeded picker follows a color's notation
199
- let mode = seeded ? meta.mode : notationOf(meta.value) ?? "oklch";
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");
200
407
  let raw: string | null = verbatim(meta.value) ? meta.value.trim() : null;
201
408
  let flavor = !seeded && isColor(meta.value) ? flavorOf(String(meta.value)) : null; // until another mode is picked
202
- let paintedHue = NaN, chromaCurve: Float64Array | null = null, chanFields: NumField[] = [];
203
- let hueRingH = NaN, hueRingBg = ""; // the hue ring depends only on H; cached
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
204
414
  const colorStr = () => raw ?? flavored(serialize([L, C, H], mode, A), flavor, [L, C, H], A);
205
415
 
206
- const root = el("div", "tw-color-body");
207
- const area = el("div", "tw-wg-area"); const areaCanvas = document.createElement("canvas"); areaCanvas.className = "tw-wg-canvas"; const areaThumb = el("div", "tw-wg-thumb"); area.append(areaCanvas, areaThumb);
208
- const hueBar = el("div", "tw-wg-hue"); const hueCanvas = document.createElement("canvas"); hueCanvas.className = "tw-wg-hue-canvas"; const hueThumb = el("div", "tw-wg-hue-thumb"); hueBar.append(hueCanvas, hueThumb);
209
- const alphaBar = el("div", "tw-wg-alpha"); const alphaGrad = el("div", "tw-wg-alpha-grad"); const alphaThumb = el("div", "tw-wg-hue-thumb"); alphaBar.append(alphaGrad, alphaThumb);
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);
210
431
  // The rasters are decoration; the plane and strips carry the names and live values.
211
- areaCanvas.setAttribute("aria-hidden", "true"); hueCanvas.setAttribute("aria-hidden", "true");
432
+ areaCanvas.setAttribute("aria-hidden", "true");
433
+ hueCanvas.setAttribute("aria-hidden", "true");
212
434
  // Keyboard-operable plane: a focusable group (no ARIA role fits a 2D value; the readout rides on aria-description).
213
- area.tabIndex = 0; area.setAttribute("role", "group"); area.setAttribute("aria-label", "Color plane: chroma across, lightness up");
214
- const modeRow = el("div", "tw-color-mode-row");
215
- const modeSel = el("select", "tw-color-mode"); modeSel.setAttribute("aria-label", "Color mode");
216
- EDIT_MODES.forEach((m) => { const o = document.createElement("option"); o.value = m; o.textContent = MODE_LABELS[m]; modeSel.append(o); });
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
+ });
217
447
  // A kept flavor shows as its own option beside its mode's, selected, so picking the mode the dropdown would otherwise
218
- // show is a change, and drops it: re-picking the shown option fires nothing, so rgb() couldn't come back from color(srgb).
219
- const keptOpt = document.createElement("option"); keptOpt.value = "kept";
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";
220
452
  const showMode = () => {
221
453
  const label = keptLabel(flavor, mode);
222
- if (label) { keptOpt.textContent = label; modeSel.querySelector(`option[value="${mode}"]`)!.after(keptOpt); modeSel.value = "kept"; }
223
- else { keptOpt.remove(); modeSel.value = 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
+ }
224
462
  };
225
463
  showMode();
226
- const gamut2 = el("span", "tw-color-gamut");
464
+ const gamut2 = el("span", "twk-color-gamut");
227
465
  modeRow.append(modeSel, gamut2);
228
- const channels = el("div", "tw-color-channels");
466
+ const channels = el("div", "twk-color-channels");
229
467
  root.append(area, hueBar, alphaBar, modeRow, channels);
230
468
 
231
469
  const actx = areaCanvas.getContext("2d", CANVAS_CS);
@@ -237,27 +475,69 @@ function createPickerBody(meta: PickerOptions, onChange: (c: string) => void): P
237
475
  let offData: ImageData | null = null;
238
476
  const renderArea = () => {
239
477
  // Layout size: getBoundingClientRect includes the pop's entrance scale.
240
- const cssW = area.clientWidth, cssH = area.clientHeight; if (cssW < 2) return;
478
+ const cssW = area.clientWidth,
479
+ cssH = area.clientHeight;
480
+ if (cssW < 2) return;
241
481
  const dpr = window.devicePixelRatio || 1;
242
482
  const stretch = showsGamutBoundary(mode) ? "p3" : "srgb";
243
- const curve = gamutCurve(H, stretch); chromaCurve = curve; // per-lightness chroma ceiling of the plane gamut
244
- const backingW = Math.round(cssW * dpr), backingH = Math.round(cssH * dpr);
245
- const W = Math.max(1, Math.round(backingW / 4)), Hh = Math.max(1, Math.round(backingH / 4)); // gradient rasterized at 1/4 res
246
- if (off.width !== W || off.height !== Hh) { off.width = W; off.height = Hh; offData = new ImageData(W, Hh, CANVAS_CS); }
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
+ }
247
495
  const data = offData.data;
248
- const invH = Hh > 1 ? 1 / (Hh - 1) : 0, invW = W > 1 ? 1 / (W - 1) : 0;
496
+ const invH = Hh > 1 ? 1 / (Hh - 1) : 0,
497
+ invW = W > 1 ? 1 / (W - 1) : 0;
249
498
  // H is constant for the raster, so fold the oklch→RGB chain once instead of a per-pixel convert().
250
- const toRgb = oklchToRgbFn(H, ENGINE_GAMUT), rgb = [0, 0, 0];
251
- for (let y = 0; y < Hh; y++) { const Lp = 1 - y * invH, rowMax = sampleCurve(curve, Lp); for (let x = 0; x < W; x++) { toRgb(Lp, x * invW * rowMax, rgb); const o = (y * W + x) * 4; data[o] = Math.round(clamp(rgb[0], 0, 1) * 255); data[o + 1] = Math.round(clamp(rgb[1], 0, 1) * 255); data[o + 2] = Math.round(clamp(rgb[2], 0, 1) * 255); data[o + 3] = 255; } }
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
+ }
252
513
  octx.putImageData(offData, 0, 0);
253
- areaCanvas.width = backingW; areaCanvas.height = backingH; actx.imageSmoothingEnabled = true; actx.drawImage(off, 0, 0, backingW, backingH);
514
+ areaCanvas.width = backingW;
515
+ areaCanvas.height = backingH;
516
+ actx.imageSmoothingEnabled = true;
517
+ actx.drawImage(off, 0, 0, backingW, backingH);
254
518
  // Gamut boundaries: solid sRGB line inside, dashed P3 line on the displayable edge.
255
519
  if (stretch !== "srgb") {
256
520
  const trace = (gamut: Gamut, color: string, width: number, dash: number[]) => {
257
- const probe = oklchGamutProbe(H, gamut), pts: [number, number][] = [];
258
- for (let s = 0; s <= 100; s++) { const Lp = s / 100, edge = sampleCurve(curve, Lp); if (edge <= 0) continue; const c = chromaCeil(probe, Lp, edge); if (c <= 0) continue; pts.push([Math.min((c / edge) * backingW, backingW - (width * dpr) / 2), (1 - Lp) * backingH]); }
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
+ }
259
531
  if (pts.length < 2) return;
260
- actx.save(); actx.strokeStyle = color; actx.lineWidth = width * dpr; actx.lineJoin = actx.lineCap = "round"; actx.setLineDash(dash.map((d) => d * dpr)); actx.beginPath(); pts.forEach((p, i) => (i ? actx.lineTo(p[0], p[1]) : actx.moveTo(p[0], p[1]))); actx.stroke(); actx.restore();
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();
261
541
  };
262
542
  trace("srgb", "rgba(255,255,255,0.7)", 1.5, []);
263
543
  trace("p3", "rgba(255,255,255,0.4)", 1, [3, 3]);
@@ -267,65 +547,133 @@ function createPickerBody(meta: PickerOptions, onChange: (c: string) => void): P
267
547
  // The strip's color at a hue: the sRGB chroma ceiling at a fixed lightness, so every hue
268
548
  // shows at its most saturated displayable form; the same fill paints the hue thumb's ring.
269
549
  const STRIP_L = 0.7;
270
- const vividHue = (hue: number) => { const rgb = convert([STRIP_L, chromaCeil(oklchGamutProbe(hue, "srgb"), STRIP_L), hue], "oklch", "srgb"); return `rgb(${clamp(rgb[0] * 255, 0, 255) | 0},${clamp(rgb[1] * 255, 0, 255) | 0},${clamp(rgb[2] * 255, 0, 255) | 0})`; };
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
+ };
271
554
  let hueW = 0;
272
555
  const renderHue = () => {
273
- const w = Math.max(1, hueBar.clientWidth), h = Math.max(1, hueBar.clientHeight); if (w < 2) return; // layout size, as above
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;
274
560
  if (w === hueW && hueCanvas.width === w) return; // the strip depends only on width
275
- hueW = w; hueCanvas.width = w; hueCanvas.height = h;
276
- for (let x = 0; x < w; x++) { hctx.fillStyle = vividHue((x / (w - 1)) * 360); hctx.fillRect(x, 0, 1, h); }
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
+ }
277
568
  };
278
569
  const positionThumbs = () => {
279
570
  const ceil = chromaCurve ? sampleCurve(chromaCurve, L) : 0.4; // thumb x is C as a fraction of the row's ceiling
280
571
  // The area thumb may overhang, so a color at the gamut boundary reads as fully selected.
281
572
  const at = (frac: number) => `${clamp(frac, 0, 1) * 100}%`;
282
573
  // The strip thumbs stay inside their track: the center runs from +w/2 at 0 to −w/2 at 1.
283
- const inside = (frac: number, w: number) => { const f = clamp(frac, 0, 1); return `calc(${f * 100}% + ${(0.5 - f) * w}px)`; };
284
- areaThumb.style.left = at(ceil > 0 ? C / ceil : 0); areaThumb.style.top = at(1 - L);
285
- hueThumb.style.left = inside(H / 360, 16); alphaThumb.style.left = inside(A, 16);
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);
286
582
  alphaBar.setAttribute("aria-valuenow", String(+A.toFixed(2)));
287
- const hDeg = Math.round(H); hueBar.setAttribute("aria-valuenow", String(hDeg)); hueBar.setAttribute("aria-valuetext", `${hDeg}°`); // H may be 360: End reads as the top of the range
288
- area.setAttribute("aria-description", `lightness ${Math.round(L * 100)}%, chroma ${+C.toFixed(3)}`);
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
+ );
289
591
  alphaGrad.style.background = `linear-gradient(to right, oklch(${L} ${C} ${H} / 0), oklch(${L} ${C} ${H}))`;
290
592
  // Filled rings: a grabbed thumb scales past its track, so each ring carries its own
291
593
  // color instead of revealing the dropdown bg in the slivers above and below the strip.
292
594
  areaThumb.style.background = `oklch(${L} ${C} ${H})`;
293
- alphaThumb.style.background = `linear-gradient(oklch(${L} ${C} ${H} / ${A}), oklch(${L} ${C} ${H} / ${A})), var(--tw-dropdown-bg)`;
294
- if (H !== hueRingH) { hueRingBg = vividHue(H); hueRingH = 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
+ }
295
600
  hueThumb.style.background = hueRingBg;
296
601
  };
297
602
  const hexText = () => serialize([L, C, H], "hex", A);
298
603
  const refresh = () => {
299
- gamut2.textContent = gamutLabel([L, C, H], mode);
300
- if (mode === "hex") { const hx = channels.querySelector<HTMLInputElement>(".tw-color-chan-input"); if (hx && document.activeElement !== hx) hx.value = hexText(); }
301
- else { const vals = channelValues([L, C, H], mode); chanFields.forEach((f, i) => { if (!f.el.contains(document.activeElement)) f.set(vals[i]); }); }
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();
302
622
  };
303
- const reflow = () => { renderHue(); renderArea(); positionThumbs(); }; // mounted at real size → rasterize + place the thumbs
304
623
  const emit = () => onChange(colorStr());
305
624
  // renderArea bails on zero width, so an offscreen repaint is a cheap no-op.
306
- const sync = (repaint: boolean) => { if (repaint && H !== paintedHue) renderArea(); positionThumbs(); refresh(); };
307
- const commit = (repaint: boolean) => { raw = null; sync(repaint); emit(); }; // an edit writes the picker's own notation
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
+ };
308
636
 
309
637
  const renderChannels = () => {
310
- channels.replaceChildren(); chanFields = [];
638
+ channels.replaceChildren();
639
+ chanFields = [];
311
640
  if (mode === "hex") {
312
- channels.classList.add("tw-color-channels--hex");
313
- const wrap = el("div", "tw-color-chan");
314
- const inp = el("input", "tw-color-chan-input"); inp.type = "text"; inp.spellcheck = false; inp.setAttribute("aria-label", "Hex color"); quietFocus(inp); selectAllOnFocus(inp); // select all: you replace a color, not a digit
315
- inp.addEventListener("change", () => { const v = inp.value.trim(); if (HEX_RE.test(v)) { [L, C, H, A] = parseColor(v); commit(true); } });
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
+ });
316
657
  // refresh() skips a focused field; catch up on blur (change fires first, so a typed edit is in).
317
658
  inp.addEventListener("blur", refresh);
318
- inp.addEventListener("keydown", (e) => { if (e.key === "Enter") inp.blur(); });
659
+ inp.addEventListener("keydown", (e) => {
660
+ if (e.key === "Enter") inp.blur();
661
+ });
319
662
  escapeReverts(inp, hexText);
320
- wrap.append(txt("span", "tw-color-chan-label", "HEX"), inp); channels.append(wrap);
663
+ wrap.append(txt("span", "twk-color-chan-label", "HEX"), inp);
664
+ channels.append(wrap);
321
665
  } else {
322
666
  // The same field as Point/Spring, so the grab handle works for color values too.
323
- channels.classList.remove("tw-color-channels--hex");
667
+ channels.classList.remove("twk-color-channels--hex");
324
668
  const vals = channelValues([L, C, H], mode);
325
669
  MODE_CHANNELS[mode].forEach((ch, i) => {
326
- const f = numField({ label: ch.k, value: vals[i], min: ch.min, max: ch.max, step: ch.step }, (v) => { [L, C, H] = withChannel([L, C, H], mode, i, clamp(v, ch.min, ch.max)); commit(true); });
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
+ });
327
674
  f.el.addEventListener("focusout", refresh); // as the hex field: catch up on the way out
328
- chanFields.push(f); channels.append(f.el);
675
+ chanFields.push(f);
676
+ channels.append(f.el);
329
677
  });
330
678
  }
331
679
  refresh();
@@ -333,77 +681,178 @@ function createPickerBody(meta: PickerOptions, onChange: (c: string) => void): P
333
681
 
334
682
  const ceilAt = (l: number) => (chromaCurve ? sampleCurve(chromaCurve, l) : 0.4);
335
683
  const areaXY = (e: PointerEvent) => boxFrac(e, area);
336
- const setArea = (e: PointerEvent) => { const [fx, fy] = areaXY(e); L = 1 - fy; C = fx * ceilAt(L); commit(false); };
684
+ const setArea = (e: PointerEvent) => {
685
+ const [fx, fy] = areaXY(e);
686
+ L = 1 - fy;
687
+ C = fx * ceilAt(L);
688
+ commit(false);
689
+ };
337
690
  grabSurface(area, setArea);
338
691
  // Arrows move the thumb, not the raw channels: chroma steps as a fraction of its row, so a
339
692
  // vertical step stays in gamut. Home/End mean nothing for a 2D point and are swallowed so they don't scroll the page.
340
693
  const say = sayWhenKeysStop(area);
341
694
  area.addEventListener("keydown", (e) => {
342
- if (e.key === "Home" || e.key === "End") { e.preventDefault(); return; }
343
- const k = e.shiftKey ? 10 : 1, [ax, ay] = arrowStep(e);
344
- const dx = ax * k, dy = ay ? ay * k : e.key === "PageUp" ? 10 : e.key === "PageDown" ? -10 : 0;
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;
345
703
  if (!dx && !dy) return;
346
704
  e.preventDefault();
347
- const c0 = ceilAt(L), fx = clamp((c0 > 0 ? C / c0 : 0) + dx * 0.01, 0, 1);
348
- L = clamp(L + dy * 0.01, 0, 1); C = fx * ceilAt(L);
349
- commit(false); say();
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();
350
711
  });
351
712
  // The hue and alpha strips: one slider each, from 0 to `max`, dragged across or stepped by the slider keys.
352
- const strip = (bar: HTMLElement, label: string, max: number, step: number, get: () => number, put: (v: number) => void) => {
353
- bar.tabIndex = 0; bar.setAttribute("role", "slider"); bar.setAttribute("aria-label", label);
354
- bar.setAttribute("aria-valuemin", "0"); bar.setAttribute("aria-valuemax", String(max));
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));
355
726
  grabSurface(bar, (e) => put(boxFrac(e, bar)[0] * max));
356
- bar.addEventListener("keydown", (e) => { const nv = rangeStep(e, get(), step, 0, max); if (nv == null) return; e.preventDefault(); put(clamp(nv, 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
+ });
357
733
  };
358
- strip(hueBar, "Hue", 360, 1, () => H, (v) => { H = v; commit(true); });
359
- strip(alphaBar, "Alpha", 1, 0.01, () => A, (v) => { A = v; commit(false); });
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
+ );
360
756
 
361
757
  // A mode switch is formatting-only and never emits: notifying would push a no-op change into
362
- // undo and rewrite a gradient's stored stop strings. The dropdown also repaints the host's trigger (meta.onMode); setMode is the host pushing a mode in, so it fires neither.
363
- const remode = (m: ColorMode) => { mode = m; raw = null; flavor = null; showMode(); renderChannels(); renderArea(); positionThumbs(); };
364
- modeSel.addEventListener("change", () => { if (modeSel.value === "kept") return; remode(modeSel.value as ColorMode); meta.onMode && meta.onMode(); }); // the dropdown holds EDIT_MODES and the kept flavor
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
+ });
365
776
 
366
777
  renderChannels();
367
778
 
368
779
  return {
369
780
  el: root,
370
781
  // Blur a focused text field before re-pointing: its change handler commits typed text against
371
- // the OLD state, so stop-hopping can't land stop A's text on stop B. Only text fields: blurring the plane reads as focus leaving the popover, which closes it.
372
- set: (v: string) => { if (!isColor(v)) { console.warn(`[tweaks] color: "${String(v).slice(0, 40)}" isn't a color — kept ${colorStr()}`); return; } const ae = document.activeElement as HTMLElement; if (ae && root.contains(ae) && ae.matches("input")) ae.blur(); [L, C, H, A] = parseColor(v); const m = seeded ? null : notationOf(v); if (m && m !== mode) remode(m); flavor = m ? flavorOf(v) : null; showMode(); raw = verbatim(v) ? v.trim() : null; sync(true); }, // remode() first: it clears raw
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
+ },
373
800
  get: () => colorStr(),
374
801
  // The gradient reads the mode to choose its blend space and re-points it on a stored ramp.
375
802
  mode: () => mode,
376
- setMode: (m: ColorMode) => { if (m !== mode && EDIT_MODES.includes(m)) remode(m); },
803
+ setMode: (m: ColorMode) => {
804
+ if (m !== mode && EDIT_MODES.includes(m)) remode(m);
805
+ },
377
806
  reflow,
378
807
  // The host paints its own trigger from these — the body carries no swatch/value of its own.
379
808
  swatchCss: () => `linear-gradient(oklch(${L} ${C} ${H} / ${A}), oklch(${L} ${C} ${H} / ${A})), ${CHECKER}`,
380
- valueText: () => readout([L, C, H], mode) + (A < 0.999 ? (mode === "hex" ? hexByte(A) : ` / ${+A.toFixed(2)}`) : ""),
809
+ valueText: () =>
810
+ readout([L, C, H], mode) + (A < 0.999 ? (mode === "hex" ? hexByte(A) : ` / ${+A.toFixed(2)}`) : ""),
381
811
  };
382
812
  }
383
813
 
384
814
  // ── Color control — a trigger row (label + swatch + value readout) that opens a picker body in a popover. ──
385
815
  function createColor(meta: Meta, onChange: OnChange): Control {
386
- const { root, trigger, right } = triggerRow("tw-color", meta.label ?? "Color"); // ??: an explicit "" label renders none
387
- const swatch = el("span", "tw-trigger-chip tw-color-swatch");
388
- const valueEl = el("span", "tw-trigger-value");
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");
389
820
  right.append(swatch, valueEl);
390
821
 
391
- const pop = el("div", "tw-color-pop");
392
- const paintTrigger = () => { swatch.style.background = body.swatchCss(); valueEl.textContent = body.valueText(); };
393
- const body = createPickerBody({ value: meta.value, mode: meta.mode as ColorMode, onMode: () => paintTrigger() }, (c) => { paintTrigger(); onChange(c); });
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
+ );
394
834
  pop.append(body.el);
395
835
  root.append(pop);
396
836
 
397
837
  popover(root, trigger, pop, { fallbackH: 340, onOpen: body.reflow, onReflow: body.reflow });
398
838
  paintTrigger();
399
839
 
400
- return { el: root, set: (v: string) => { if (v == null) return; body.set(v); paintTrigger(); }, get: () => body.get() };
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
+ };
401
849
  }
402
850
 
403
851
  /** An oklch() string at display precision, alpha appended only when translucent. */
404
- const oklchStr = (L: number, C: number, H: number, A: number) => A < 0.999
405
- ? `oklch(${+L.toFixed(4)} ${+C.toFixed(4)} ${+H.toFixed(2)} / ${+A.toFixed(3)})`
406
- : `oklch(${+L.toFixed(4)} ${+C.toFixed(4)} ${+H.toFixed(2)})`;
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)})`;
407
856
 
408
857
  export { createPickerBody, parseColor, oklchStr, CHECKER };
409
858
  registerControl("color", createColor);