tweakit 0.5.2 → 0.6.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 +31 -29
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ── Cubic bezier — interactive easing-curve editor. Lazy.
|
|
2
|
+
import { el, btn, txt, clamp, onReady, onLive, registerControl } from "../shared.js";
|
|
3
|
+
import { dragGesture, numField, svgEl, arrowStep } from "../heavy.js";
|
|
4
|
+
import type { OnChange, Built } from "../shared.js";
|
|
5
|
+
import type { Meta } from "../schema.js";
|
|
6
|
+
import type { Control } from "../types.js";
|
|
7
|
+
|
|
8
|
+
function createBezier(meta: Meta, onChange: OnChange): Control {
|
|
9
|
+
const DEF = [0.25, 0.1, 0.25, 1];
|
|
10
|
+
// x must stay 0–1 for a valid cubic-bezier(); y may be anything in CSS, and −1–2 holds the overshoot curves people reach
|
|
11
|
+
// for (easeOutBack's 1.56, easeInOutBack's −0.6 and 1.6), which a −0.25–1.25 clamp flattened without a word.
|
|
12
|
+
const YMIN = -1, YMAX = 2, PAD = 8;
|
|
13
|
+
// Fit on build as well as in set(), so an authored [1.5, 0, -0.2, 1] can't reach get() as an invalid cubic-bezier().
|
|
14
|
+
const fit = (m: number[]) => m.map((n, i) => clamp(n, i % 2 ? YMIN : 0, i % 2 ? YMAX : 1));
|
|
15
|
+
// The drawn y range: −0.25–1.25, grown to fit the handles. A drag holds it still, so the curve doesn't slide under the
|
|
16
|
+
// pointer, and the graph fits again when the drag ends.
|
|
17
|
+
let view = [-0.25, 1.25], held = false;
|
|
18
|
+
const fitView = () => { if (!held) view = [Math.min(-0.25, v[1] - 0.1, v[3] - 0.1), Math.max(1.25, v[1] + 0.1, v[3] + 0.1)]; };
|
|
19
|
+
let v: number[] = Array.isArray(meta.value) && meta.value.length === 4 ? meta.value.map(Number) : DEF.slice();
|
|
20
|
+
if (v.some((n) => !Number.isFinite(n))) v = DEF.slice(); // a non-finite init would show NaN and a blank curve
|
|
21
|
+
v = fit(v);
|
|
22
|
+
const def = v.slice(); // fitted default; Home on a handle returns to it
|
|
23
|
+
|
|
24
|
+
const root = el("div", "tw-bezier");
|
|
25
|
+
if (meta.label) root.append(txt("div", "tw-bezier-label", meta.label)); // a caption; an explicit "" skips it
|
|
26
|
+
const graph = el("div", "tw-bezier-graph");
|
|
27
|
+
const svg = svgEl("svg", "tw-bezier-svg"); svg.setAttribute("preserveAspectRatio", "none");
|
|
28
|
+
const unitBot = svgEl("line", "tw-bezier-guide"), unitTop = svgEl("line", "tw-bezier-guide");
|
|
29
|
+
const diag = svgEl("line", "tw-bezier-diag"), line1 = svgEl("line", "tw-bezier-ctl"), line2 = svgEl("line", "tw-bezier-ctl");
|
|
30
|
+
const curve = svgEl("path", "tw-bezier-curve");
|
|
31
|
+
svg.append(unitBot, unitTop, diag, line1, line2, curve);
|
|
32
|
+
const h1 = btn("tw-bezier-handle"); h1.setAttribute("aria-label", "Control point 1");
|
|
33
|
+
const h2 = btn("tw-bezier-handle"); h2.setAttribute("aria-label", "Control point 2");
|
|
34
|
+
graph.append(svg, h1, h2);
|
|
35
|
+
const fields = el("div", "tw-fields tw-bezier-fields");
|
|
36
|
+
root.append(graph, fields);
|
|
37
|
+
|
|
38
|
+
let W = 0, H = 0;
|
|
39
|
+
const xPx = (x: number) => PAD + x * (W - 2 * PAD);
|
|
40
|
+
const yPx = (y: number) => (H - PAD) - ((y - view[0]) / (view[1] - view[0])) * (H - 2 * PAD);
|
|
41
|
+
const setLine = (ln: Built<SVGLineElement>, ax: number, ay: number, bx: number, by: number) => { ln.setAttribute("x1", ax); ln.setAttribute("y1", ay); ln.setAttribute("x2", bx); ln.setAttribute("y2", by); };
|
|
42
|
+
// The curve and handles only; the fields update separately, so a field edit doesn't re-set itself.
|
|
43
|
+
const drawGraph = () => {
|
|
44
|
+
// No ARIA role fits a 2D point, so the labels carry the coordinates; set before the size guard for hidden tab pages.
|
|
45
|
+
h1.setAttribute("aria-label", `Control point 1, x ${v[0]}, y ${v[1]}`); h2.setAttribute("aria-label", `Control point 2, x ${v[2]}, y ${v[3]}`);
|
|
46
|
+
// Layout px, not getBoundingClientRect: under an ancestor CSS scale the rect sat the handles off the curve.
|
|
47
|
+
W = graph.offsetWidth; H = graph.offsetHeight; if (W < 2) return;
|
|
48
|
+
fitView();
|
|
49
|
+
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
|
50
|
+
const [x1, y1, x2, y2] = v;
|
|
51
|
+
const ax = xPx(0), ay = yPx(0), bx = xPx(1), by = yPx(1), p1x = xPx(x1), p1y = yPx(y1), p2x = xPx(x2), p2y = yPx(y2);
|
|
52
|
+
setLine(unitBot, PAD, ay, W - PAD, ay); setLine(unitTop, PAD, by, W - PAD, by);
|
|
53
|
+
setLine(diag, ax, ay, bx, by); setLine(line1, ax, ay, p1x, p1y); setLine(line2, bx, by, p2x, p2y);
|
|
54
|
+
curve.setAttribute("d", `M${ax},${ay} C${p1x},${p1y} ${p2x},${p2y} ${bx},${by}`);
|
|
55
|
+
h1.style.left = p1x + "px"; h1.style.top = p1y + "px"; h2.style.left = p2x + "px"; h2.style.top = p2y + "px";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const SPECS = [{ label: "X1", i: 0, lo: 0, hi: 1 }, { label: "Y1", i: 1, lo: YMIN, hi: YMAX }, { label: "X2", i: 2, lo: 0, hi: 1 }, { label: "Y2", i: 3, lo: YMIN, hi: YMAX }];
|
|
59
|
+
const flds = SPECS.map((sp) => {
|
|
60
|
+
const fld = numField({ label: sp.label, value: v[sp.i], step: 0.01, min: sp.lo, max: sp.hi }, (val) => { v[sp.i] = val; drawGraph(); onChange(v.slice()); });
|
|
61
|
+
fields.append(fld.el); return fld;
|
|
62
|
+
});
|
|
63
|
+
const syncFields = () => flds.forEach((fld, k) => fld.set(v[SPECS[k].i]));
|
|
64
|
+
|
|
65
|
+
const drag = (handle: HTMLButtonElement, idx: number) => {
|
|
66
|
+
let rect: DOMRect | null = null, gw = 0, gh = 0, scale = 1;
|
|
67
|
+
dragGesture(handle, {
|
|
68
|
+
// Divide out any ancestor CSS transform (rect is visual px, offsetWidth layout px); preventDefault killed click-to-focus.
|
|
69
|
+
onDown: (e) => { e.preventDefault(); rect = graph.getBoundingClientRect(); gw = graph.offsetWidth || rect.width; gh = graph.offsetHeight || rect.height; scale = rect.width / gw; handle.classList.add("is-dragging"); handle.focus({ focusVisible: false }); held = true; },
|
|
70
|
+
onMove: (e) => {
|
|
71
|
+
const gx = (e.clientX - rect.left) / scale, gy = (e.clientY - rect.top) / scale;
|
|
72
|
+
const x = clamp((gx - PAD) / (gw - 2 * PAD), 0, 1);
|
|
73
|
+
const y = clamp(view[0] + ((gh - PAD) - gy) / (gh - 2 * PAD) * (view[1] - view[0]), YMIN, YMAX);
|
|
74
|
+
v[idx * 2] = +x.toFixed(2); v[idx * 2 + 1] = +y.toFixed(2); drawGraph(); syncFields(); onChange(v.slice());
|
|
75
|
+
},
|
|
76
|
+
onEnd: () => { rect = null; handle.classList.remove("is-dragging"); held = false; drawGraph(); },
|
|
77
|
+
});
|
|
78
|
+
// Keyboard: arrows nudge by 0.01 (⇧ = 0.1) on the drag's clamps and grid, Home restores the default, Escape drops focus.
|
|
79
|
+
handle.addEventListener("keydown", (e) => {
|
|
80
|
+
if (e.key === "Escape") return handle.blur();
|
|
81
|
+
if (e.key === "Home") { e.preventDefault(); v = def.slice(); drawGraph(); syncFields(); onChange(v.slice()); return; }
|
|
82
|
+
const [dx, dy] = arrowStep(e);
|
|
83
|
+
if (!dx && !dy) return;
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
const k = e.shiftKey ? 0.1 : 0.01, i = idx * 2;
|
|
86
|
+
if (dx) v[i] = +clamp(v[i] + dx * k, 0, 1).toFixed(2);
|
|
87
|
+
if (dy) v[i + 1] = +clamp(v[i + 1] + dy * k, YMIN, YMAX).toFixed(2);
|
|
88
|
+
drawGraph(); syncFields(); onChange(v.slice());
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
drag(h1, 0); drag(h2, 1);
|
|
92
|
+
|
|
93
|
+
onReady(drawGraph);
|
|
94
|
+
onLive(root, [[window, "resize"], [window, "tw-reflow"]], drawGraph); // tw-reflow: a tab page revealing this re-measures it
|
|
95
|
+
return { el: root, set: (nv: unknown) => { if (Array.isArray(nv) && nv.length === 4) { const m = nv.map(Number); if (m.some((n) => !Number.isFinite(n))) return; v = fit(m); drawGraph(); syncFields(); } }, get: () => v.slice() }; // the build's clamp, so get() agrees with the handles
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
registerControl("cubicbezier", createBezier);
|
|
99
|
+
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// ── Color — wide-gamut OKLCH picker. Lazy: dynamic-imported on first use, and
|
|
2
|
+
// the only module that loads wide-gamut.js (so basic panels never pay for it).
|
|
3
|
+
import { el, txt, clamp, rangeStep, popover, quietFocus, 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";
|
|
6
|
+
import type { Vec3, Space, Gamut } from "../../wide-gamut.js";
|
|
7
|
+
import type { OnChange, NumField } from "../shared.js";
|
|
8
|
+
import type { Meta } from "../schema.js";
|
|
9
|
+
import type { Control, ColorMode } from "../types.js";
|
|
10
|
+
|
|
11
|
+
/** A parsed color: OKLCH plus alpha. */
|
|
12
|
+
export type Oklcha = [number, number, number, number];
|
|
13
|
+
/** 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 }
|
|
15
|
+
/** 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 }
|
|
17
|
+
|
|
18
|
+
// ── Color-area rendering. The plane stretches to the P3 gamut, drawn at device-pixel
|
|
19
|
+
// 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; } })();
|
|
21
|
+
const CANVAS_CS: CanvasRenderingContext2DSettings & ImageDataSettings = WIDE_CANVAS ? { colorSpace: "display-p3" } : {};
|
|
22
|
+
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
|
|
26
|
+
|
|
27
|
+
// Parsing: any CSS color (hex / oklch / oklab / lab / lch / color() / rgb / hsl / named) → [L, C, H, alpha].
|
|
28
|
+
// The CSS Color 4 functions are parsed by regex + the engine; the canvas fallback is only safe
|
|
29
|
+
// for sRGB-family colors, since its fillStyle getter echoes wide colors in their own notation.
|
|
30
|
+
// Null-prototype: the space name comes from the parsed string, so `color(constructor …)` must
|
|
31
|
+
// 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
|
|
34
|
+
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
|
|
36
|
+
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
|
+
// The one scratch 2D context; null where the DOM has no canvas (jsdom), and that is cached too.
|
|
38
|
+
let c2dCache: CanvasRenderingContext2D | null;
|
|
39
|
+
const canvas2d = () => (c2dCache === undefined ? (c2dCache = document.createElement("canvas").getContext("2d")) : c2dCache);
|
|
40
|
+
// Shape first, then grammar: a stop's text is concatenated into a `linear-gradient()`, where a
|
|
41
|
+
// stray ")" could splice in a url(), and a color-shaped `oklch(a b c)` makes a browser drop the
|
|
42
|
+
// whole gradient. A bare keyword is put to the canvas against two sentinels, so a legitimate
|
|
43
|
+
// `black` isn't mistaken for the "junk leaves fillStyle unchanged" echo.
|
|
44
|
+
const COLOR_TOKEN = /^(#[0-9a-f]{3,8}|[a-z][a-z-]*(?:\([\w\s.,%+\-\/]*\))?)$/i;
|
|
45
|
+
/** Is `str` one CSS color token and nothing more? The gate a gradient stop passes before it reaches a style sink. */
|
|
46
|
+
// 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(" "));
|
|
48
|
+
export function isColor(str: string | null | undefined) {
|
|
49
|
+
str = String(str == null ? "" : str).trim();
|
|
50
|
+
if (!COLOR_TOKEN.test(str)) return false;
|
|
51
|
+
if (HEX_RE.test(str)) return true;
|
|
52
|
+
if (ENGINE_FN_RE.test(str)) return engineFn(str);
|
|
53
|
+
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; });
|
|
56
|
+
}
|
|
57
|
+
// The sRGB-family functions by CSS Color 4's own grammar — modern and legacy syntax, `none`,
|
|
58
|
+
// percentages, angle units — and nothing looser, so a color-shaped string CSS would reject
|
|
59
|
+
// (`rgb(a b c)`, a mixed legacy `rgb(255, 0%, 0)`) is null here. Channels clamp where CSS clamps.
|
|
60
|
+
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
|
|
62
|
+
const cssTrim = (t: string) => t.replace(/^[ \t\n\r\f]+|[ \t\n\r\f]+$/g, "");
|
|
63
|
+
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);
|
|
65
|
+
// 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); };
|
|
67
|
+
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];
|
|
70
|
+
let ch: string[], alpha: string | undefined, kinds: Array<ReturnType<typeof tokKind>>;
|
|
71
|
+
if (body.includes(",")) { // legacy: rgb()/hsl() only, no none, no mixed units
|
|
72
|
+
if (fn === "hwb") return null;
|
|
73
|
+
const parts = body.split(",").map(cssTrim);
|
|
74
|
+
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;
|
|
77
|
+
if (alpha !== undefined && tokKind(alpha) !== "num" && tokKind(alpha) !== "pct") return null;
|
|
78
|
+
} 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; }
|
|
82
|
+
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;
|
|
84
|
+
}
|
|
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
|
|
86
|
+
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");
|
|
90
|
+
return [k, A];
|
|
91
|
+
}
|
|
92
|
+
// The engine's own functions by the same standard: three channel tokens (the hue slot of
|
|
93
|
+
// lch()/oklch() takes an angle, not a percentage), an optional `/ alpha`, and for color() a predefined space first.
|
|
94
|
+
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;
|
|
97
|
+
const toks = slash[0].split(CSS_WS).filter(Boolean);
|
|
98
|
+
if (fn === "color" && !CSS_SPACES.test(toks.shift() || "")) return false;
|
|
99
|
+
if (toks.length !== 3) return false;
|
|
100
|
+
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; }
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
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];
|
|
108
|
+
function parseColor(str: string | null | undefined): Oklcha {
|
|
109
|
+
str = String(str == null ? "" : str).trim();
|
|
110
|
+
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];
|
|
115
|
+
}
|
|
116
|
+
const m = ENGINE_FN_RE.exec(str);
|
|
117
|
+
if (m) {
|
|
118
|
+
const fn = m[1].toLowerCase();
|
|
119
|
+
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));
|
|
124
|
+
const A = aRaw ? clamp(ch(aRaw, 0.01), 0, 1) : 1;
|
|
125
|
+
// L and chroma clamp at parse time, as CSS Color 4 does.
|
|
126
|
+
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
|
|
132
|
+
return held(space === "oklch" ? c : convert(c, space, "oklch"), A);
|
|
133
|
+
}
|
|
134
|
+
// 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]; }
|
|
136
|
+
// Named colors and `transparent` normalize via a canvas, whose fillStyle getter echoes
|
|
137
|
+
// "#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;
|
|
140
|
+
const norm = c2d.fillStyle as string; // a string was set, so the getter echoes a string
|
|
141
|
+
if (HEX_RE.test(norm)) return parseColor(norm); // gated on the hex shape, so the recursion is bounded
|
|
142
|
+
const rm = norm.match(/^rgba?\(([^)]*)\)/i);
|
|
143
|
+
const cm = ((rm ? rm[1] : "").match(/-?[\d.]+(?:e[+-]?\d+)?/gi) || [0, 0, 0]).map(Number);
|
|
144
|
+
const k = convert([cm[0] / 255, cm[1] / 255, cm[2] / 255], "srgb", "oklch");
|
|
145
|
+
return [num(k[0]), num(k[1]), num(k[2]), cm[3] != null ? clamp(num(cm[3]), 0, 1) : 1];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 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));
|
|
152
|
+
// 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
|
+
// alpha in a form the picker can't echo), which opens in OKLCH.
|
|
154
|
+
const notationOf = (v: unknown): ColorMode | null => {
|
|
155
|
+
const t = String(v ?? "").trim().toLowerCase();
|
|
156
|
+
if (HEX_RE.test(t)) return "hex";
|
|
157
|
+
const fn = /^([a-z]+)\(\s*([a-z0-9-]*)/.exec(t);
|
|
158
|
+
if (!fn) return null;
|
|
159
|
+
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;
|
|
162
|
+
};
|
|
163
|
+
// What a written color's notation says beyond its mode, kept through an edit until another mode is picked: the name
|
|
164
|
+
// (rgba, hsla), legacy commas and an alpha slot, color(srgb …) for rgb(). An edit wrote color(srgb 1 0 0) as rgb().
|
|
165
|
+
type Flavor = { name: string; legacy: boolean; alphaSlot: boolean };
|
|
166
|
+
const flavorOf = (v: string): Flavor | null => {
|
|
167
|
+
const m = /^\s*(rgba?|hsla?|color)\(\s*([^)]*)\)\s*$/i.exec(v);
|
|
168
|
+
if (!m) return null;
|
|
169
|
+
const name = m[1].toLowerCase(), args = m[2].split(",").length;
|
|
170
|
+
if (name === "color") return /^srgb\s/i.test(m[2]) ? { name, legacy: false, alphaSlot: false } : null;
|
|
171
|
+
return { name, legacy: args > 1, alphaSlot: args === 4 };
|
|
172
|
+
};
|
|
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).
|
|
176
|
+
const keptLabel = (f: Flavor | null, mode: ColorMode) => {
|
|
177
|
+
if (!f) return null;
|
|
178
|
+
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 ? "," : ""})`;
|
|
181
|
+
};
|
|
182
|
+
// serialize()'s string in the written flavor, rebuilt from its numbers. Legacy syntax has no `none`: a gray's hue writes 0.
|
|
183
|
+
const flavored = (s: string, f: Flavor | null, oklch: Vec3, A: number) => {
|
|
184
|
+
if (!f) return s;
|
|
185
|
+
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
|
|
187
|
+
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}`})`;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ── Picker body ──
|
|
192
|
+
/** 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. */
|
|
194
|
+
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)
|
|
196
|
+
// 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";
|
|
200
|
+
let raw: string | null = verbatim(meta.value) ? meta.value.trim() : null;
|
|
201
|
+
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
|
|
204
|
+
const colorStr = () => raw ?? flavored(serialize([L, C, H], mode, A), flavor, [L, C, H], A);
|
|
205
|
+
|
|
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);
|
|
210
|
+
// 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");
|
|
212
|
+
// 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); });
|
|
217
|
+
// 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";
|
|
220
|
+
const showMode = () => {
|
|
221
|
+
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; }
|
|
224
|
+
};
|
|
225
|
+
showMode();
|
|
226
|
+
const gamut2 = el("span", "tw-color-gamut");
|
|
227
|
+
modeRow.append(modeSel, gamut2);
|
|
228
|
+
const channels = el("div", "tw-color-channels");
|
|
229
|
+
root.append(area, hueBar, alphaBar, modeRow, channels);
|
|
230
|
+
|
|
231
|
+
const actx = areaCanvas.getContext("2d", CANVAS_CS);
|
|
232
|
+
const hctx = hueCanvas.getContext("2d");
|
|
233
|
+
|
|
234
|
+
// One offscreen raster per body: a hue drag repaints every move.
|
|
235
|
+
const off = document.createElement("canvas");
|
|
236
|
+
const octx = off.getContext("2d", CANVAS_CS);
|
|
237
|
+
let offData: ImageData | null = null;
|
|
238
|
+
const renderArea = () => {
|
|
239
|
+
// Layout size: getBoundingClientRect includes the pop's entrance scale.
|
|
240
|
+
const cssW = area.clientWidth, cssH = area.clientHeight; if (cssW < 2) return;
|
|
241
|
+
const dpr = window.devicePixelRatio || 1;
|
|
242
|
+
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); }
|
|
247
|
+
const data = offData.data;
|
|
248
|
+
const invH = Hh > 1 ? 1 / (Hh - 1) : 0, invW = W > 1 ? 1 / (W - 1) : 0;
|
|
249
|
+
// 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; } }
|
|
252
|
+
octx.putImageData(offData, 0, 0);
|
|
253
|
+
areaCanvas.width = backingW; areaCanvas.height = backingH; actx.imageSmoothingEnabled = true; actx.drawImage(off, 0, 0, backingW, backingH);
|
|
254
|
+
// Gamut boundaries: solid sRGB line inside, dashed P3 line on the displayable edge.
|
|
255
|
+
if (stretch !== "srgb") {
|
|
256
|
+
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]); }
|
|
259
|
+
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();
|
|
261
|
+
};
|
|
262
|
+
trace("srgb", "rgba(255,255,255,0.7)", 1.5, []);
|
|
263
|
+
trace("p3", "rgba(255,255,255,0.4)", 1, [3, 3]);
|
|
264
|
+
}
|
|
265
|
+
paintedHue = H;
|
|
266
|
+
};
|
|
267
|
+
// The strip's color at a hue: the sRGB chroma ceiling at a fixed lightness, so every hue
|
|
268
|
+
// shows at its most saturated displayable form; the same fill paints the hue thumb's ring.
|
|
269
|
+
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})`; };
|
|
271
|
+
let hueW = 0;
|
|
272
|
+
const renderHue = () => {
|
|
273
|
+
const w = Math.max(1, hueBar.clientWidth), h = Math.max(1, hueBar.clientHeight); if (w < 2) return; // layout size, as above
|
|
274
|
+
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); }
|
|
277
|
+
};
|
|
278
|
+
const positionThumbs = () => {
|
|
279
|
+
const ceil = chromaCurve ? sampleCurve(chromaCurve, L) : 0.4; // thumb x is C as a fraction of the row's ceiling
|
|
280
|
+
// The area thumb may overhang, so a color at the gamut boundary reads as fully selected.
|
|
281
|
+
const at = (frac: number) => `${clamp(frac, 0, 1) * 100}%`;
|
|
282
|
+
// 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);
|
|
286
|
+
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)}`);
|
|
289
|
+
alphaGrad.style.background = `linear-gradient(to right, oklch(${L} ${C} ${H} / 0), oklch(${L} ${C} ${H}))`;
|
|
290
|
+
// Filled rings: a grabbed thumb scales past its track, so each ring carries its own
|
|
291
|
+
// color instead of revealing the dropdown bg in the slivers above and below the strip.
|
|
292
|
+
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; }
|
|
295
|
+
hueThumb.style.background = hueRingBg;
|
|
296
|
+
};
|
|
297
|
+
const hexText = () => serialize([L, C, H], "hex", A);
|
|
298
|
+
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]); }); }
|
|
302
|
+
};
|
|
303
|
+
const reflow = () => { renderHue(); renderArea(); positionThumbs(); }; // mounted at real size → rasterize + place the thumbs
|
|
304
|
+
const emit = () => onChange(colorStr());
|
|
305
|
+
// 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
|
|
308
|
+
|
|
309
|
+
const renderChannels = () => {
|
|
310
|
+
channels.replaceChildren(); chanFields = [];
|
|
311
|
+
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); } });
|
|
316
|
+
// refresh() skips a focused field; catch up on blur (change fires first, so a typed edit is in).
|
|
317
|
+
inp.addEventListener("blur", refresh);
|
|
318
|
+
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") inp.blur(); });
|
|
319
|
+
escapeReverts(inp, hexText);
|
|
320
|
+
wrap.append(txt("span", "tw-color-chan-label", "HEX"), inp); channels.append(wrap);
|
|
321
|
+
} else {
|
|
322
|
+
// The same field as Point/Spring, so the grab handle works for color values too.
|
|
323
|
+
channels.classList.remove("tw-color-channels--hex");
|
|
324
|
+
const vals = channelValues([L, C, H], mode);
|
|
325
|
+
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); });
|
|
327
|
+
f.el.addEventListener("focusout", refresh); // as the hex field: catch up on the way out
|
|
328
|
+
chanFields.push(f); channels.append(f.el);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
refresh();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const ceilAt = (l: number) => (chromaCurve ? sampleCurve(chromaCurve, l) : 0.4);
|
|
335
|
+
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); };
|
|
337
|
+
grabSurface(area, setArea);
|
|
338
|
+
// Arrows move the thumb, not the raw channels: chroma steps as a fraction of its row, so a
|
|
339
|
+
// vertical step stays in gamut. Home/End mean nothing for a 2D point and are swallowed so they don't scroll the page.
|
|
340
|
+
const say = sayWhenKeysStop(area);
|
|
341
|
+
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;
|
|
345
|
+
if (!dx && !dy) return;
|
|
346
|
+
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();
|
|
350
|
+
});
|
|
351
|
+
// 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));
|
|
355
|
+
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)); });
|
|
357
|
+
};
|
|
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); });
|
|
360
|
+
|
|
361
|
+
// 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
|
|
365
|
+
|
|
366
|
+
renderChannels();
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
el: root,
|
|
370
|
+
// 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
|
|
373
|
+
get: () => colorStr(),
|
|
374
|
+
// The gradient reads the mode to choose its blend space and re-points it on a stored ramp.
|
|
375
|
+
mode: () => mode,
|
|
376
|
+
setMode: (m: ColorMode) => { if (m !== mode && EDIT_MODES.includes(m)) remode(m); },
|
|
377
|
+
reflow,
|
|
378
|
+
// The host paints its own trigger from these — the body carries no swatch/value of its own.
|
|
379
|
+
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)}`) : ""),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Color control — a trigger row (label + swatch + value readout) that opens a picker body in a popover. ──
|
|
385
|
+
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");
|
|
389
|
+
right.append(swatch, valueEl);
|
|
390
|
+
|
|
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); });
|
|
394
|
+
pop.append(body.el);
|
|
395
|
+
root.append(pop);
|
|
396
|
+
|
|
397
|
+
popover(root, trigger, pop, { fallbackH: 340, onOpen: body.reflow, onReflow: body.reflow });
|
|
398
|
+
paintTrigger();
|
|
399
|
+
|
|
400
|
+
return { el: root, set: (v: string) => { if (v == null) return; body.set(v); paintTrigger(); }, get: () => body.get() };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 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)})`;
|
|
407
|
+
|
|
408
|
+
export { createPickerBody, parseColor, oklchStr, CHECKER };
|
|
409
|
+
registerControl("color", createColor);
|
|
410
|
+
// Registered beside its control so createColorPicker() (standalone.ts) reaches it through the
|
|
411
|
+
// registry without a static import that would pull the color engine into the basic bundle.
|
|
412
|
+
registerControl("colorpicker", createPickerBody);
|