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.
- package/AGENTS.md +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /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 {
|
|
5
|
-
|
|
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 {
|
|
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 {
|
|
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 = (() => {
|
|
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) => {
|
|
24
|
-
const
|
|
25
|
-
|
|
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), {
|
|
33
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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(
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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) =>
|
|
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) => {
|
|
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);
|
|
69
|
-
|
|
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(",")) {
|
|
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);
|
|
76
|
-
|
|
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("/");
|
|
80
|
-
|
|
81
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
96
|
-
|
|
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 (
|
|
102
|
-
|
|
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 =
|
|
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 => [
|
|
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("#", ""),
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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),
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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)) {
|
|
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();
|
|
139
|
-
c2d
|
|
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
|
|
150
|
-
//
|
|
151
|
-
|
|
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 ?? "")
|
|
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")
|
|
161
|
-
|
|
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(),
|
|
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
|
|
174
|
-
// short as the mode names, since a select is as wide as its widest option; null when the mode writes it anyway
|
|
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 =
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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,
|
|
203
|
-
|
|
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", "
|
|
207
|
-
const area = el("div", "
|
|
208
|
-
const
|
|
209
|
-
|
|
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");
|
|
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;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
219
|
-
|
|
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) {
|
|
223
|
-
|
|
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", "
|
|
464
|
+
const gamut2 = el("span", "twk-color-gamut");
|
|
227
465
|
modeRow.append(modeSel, gamut2);
|
|
228
|
-
const channels = el("div", "
|
|
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,
|
|
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);
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
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,
|
|
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),
|
|
251
|
-
|
|
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;
|
|
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),
|
|
258
|
-
|
|
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();
|
|
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) => {
|
|
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
|
-
|
|
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;
|
|
276
|
-
|
|
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) => {
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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(--
|
|
294
|
-
if (H !== hueRingH) {
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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) => {
|
|
307
|
-
|
|
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();
|
|
638
|
+
channels.replaceChildren();
|
|
639
|
+
chanFields = [];
|
|
311
640
|
if (mode === "hex") {
|
|
312
|
-
channels.classList.add("
|
|
313
|
-
const wrap = el("div", "
|
|
314
|
-
|
|
315
|
-
|
|
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) => {
|
|
659
|
+
inp.addEventListener("keydown", (e) => {
|
|
660
|
+
if (e.key === "Enter") inp.blur();
|
|
661
|
+
});
|
|
319
662
|
escapeReverts(inp, hexText);
|
|
320
|
-
wrap.append(txt("span", "
|
|
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("
|
|
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) => {
|
|
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);
|
|
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) => {
|
|
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") {
|
|
343
|
-
|
|
344
|
-
|
|
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),
|
|
348
|
-
|
|
349
|
-
|
|
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 = (
|
|
353
|
-
bar
|
|
354
|
-
|
|
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) => {
|
|
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(
|
|
359
|
-
|
|
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);
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
372
|
-
|
|
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) => {
|
|
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: () =>
|
|
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
|
-
|
|
387
|
-
const
|
|
388
|
-
const
|
|
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", "
|
|
392
|
-
const paintTrigger = () => {
|
|
393
|
-
|
|
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 {
|
|
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) =>
|
|
405
|
-
|
|
406
|
-
|
|
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);
|