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.
Files changed (111) hide show
  1. package/AGENTS.md +31 -29
  2. package/README.md +55 -29
  3. package/THIRD-PARTY-NOTICES.md +0 -1
  4. package/dist/tweaks/auto.js +2 -0
  5. package/dist/tweaks/auto.js.map +6 -0
  6. package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
  7. package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
  8. package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
  9. package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
  10. package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
  11. package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
  12. package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
  13. package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
  14. package/dist/tweaks/clip-WW7JYO4X.js +2 -0
  15. package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
  16. package/dist/tweaks/color-YMMFURP6.js +2 -0
  17. package/dist/tweaks/color-YMMFURP6.js.map +6 -0
  18. package/dist/tweaks/core.js +2 -1
  19. package/dist/tweaks/core.js.map +6 -0
  20. package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
  21. package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
  22. package/dist/tweaks/gradient-JER7PW4D.js +2 -0
  23. package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
  24. package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
  25. package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
  26. package/dist/tweaks/image-H6QSLGO3.js +4 -0
  27. package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
  28. package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
  29. package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
  30. package/dist/tweaks/interval-TEUHQGZN.js +2 -0
  31. package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
  32. package/dist/tweaks/markup-NIBJD6E2.js +2 -0
  33. package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
  34. package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
  35. package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
  36. package/dist/tweaks/motion-FQPUBKKM.js +2 -0
  37. package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
  38. package/dist/tweaks/plot-2HBNBZSG.js +3 -0
  39. package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
  40. package/dist/tweaks/point-VV6XV3I6.js +2 -0
  41. package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
  42. package/dist/tweaks/shadow-5E4E55I2.js +2 -0
  43. package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
  44. package/dist/tweaks/sides-P3STEVYY.js +2 -0
  45. package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
  46. package/dist/tweaks/spring-HUVSXNVN.js +2 -0
  47. package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
  48. package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
  49. package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
  50. package/dist/tweaks.css +1 -1
  51. package/dist/tweaks.js +6 -3
  52. package/dist/tweaks.js.map +6 -0
  53. package/dist/types/tweaks/easing.d.ts +8 -11
  54. package/dist/types/tweaks/panel.d.ts +6 -4
  55. package/dist/types/tweaks/schema.d.ts +4 -8
  56. package/dist/types/tweaks/standalone.d.ts +3 -3
  57. package/dist/types/tweaks/types.d.ts +278 -101
  58. package/package.json +27 -5
  59. package/src/tweaks/auto.ts +12 -0
  60. package/src/tweaks/clip.ts +94 -0
  61. package/src/tweaks/controls/basic.ts +403 -0
  62. package/src/tweaks/controls/bezier.ts +99 -0
  63. package/src/tweaks/controls/color.ts +412 -0
  64. package/src/tweaks/controls/gradient.ts +240 -0
  65. package/src/tweaks/controls/image.ts +299 -0
  66. package/src/tweaks/controls/interval.ts +104 -0
  67. package/src/tweaks/controls/monitor.ts +105 -0
  68. package/src/tweaks/controls/motion.ts +141 -0
  69. package/src/tweaks/controls/plot.ts +220 -0
  70. package/src/tweaks/controls/point.ts +119 -0
  71. package/src/tweaks/controls/shadow.ts +83 -0
  72. package/src/tweaks/controls/sides.ts +86 -0
  73. package/src/tweaks/controls/spring.ts +148 -0
  74. package/src/tweaks/controls/tabs.ts +47 -0
  75. package/src/tweaks/core.ts +9 -0
  76. package/src/tweaks/easing.ts +93 -0
  77. package/src/tweaks/enhance.ts +20 -0
  78. package/src/tweaks/feedback.ts +119 -0
  79. package/src/tweaks/fold.ts +21 -0
  80. package/src/tweaks/heavy.ts +302 -0
  81. package/src/tweaks/icons.ts +19 -0
  82. package/src/tweaks/image-store.ts +101 -0
  83. package/src/tweaks/lazy.ts +46 -0
  84. package/src/tweaks/markup.ts +154 -0
  85. package/src/tweaks/panel.ts +869 -0
  86. package/src/tweaks/schema.ts +189 -0
  87. package/src/tweaks/shared.ts +525 -0
  88. package/src/tweaks/single.ts +22 -0
  89. package/src/tweaks/standalone.ts +89 -0
  90. package/src/tweaks/types.ts +526 -0
  91. package/src/tweaks.css +959 -0
  92. package/src/wide-gamut.ts +310 -0
  93. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  94. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  95. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  96. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  97. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  98. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  99. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  100. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  101. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  102. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  103. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  104. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  105. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  106. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  107. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  108. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  109. package/dist/tweaks/sides-FH227Z54.js +0 -1
  110. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  111. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,240 @@
1
+ // ── Gradient — a wide-gamut OKLCH gradient editor. Lazy; depends on the color
2
+ // module (reuses its picker body, parseColor, and oklchStr).
3
+ import { el, btn, clamp, popover, registerControl, icon } from "../shared.js";
4
+ import { dragGesture, triggerRow, arrowStep } from "../heavy.js";
5
+ import { createPickerBody, parseColor, isColor, oklchStr, CHECKER } from "./color.js";
6
+ import { modeInterpolation, interpolationMode } from "../../wide-gamut.js";
7
+ import { gradientCss, parseEasing, easingSamples } from "../easing.js";
8
+ import type { Bez } from "../easing.js";
9
+ import type { Oklcha } from "./color.js";
10
+ import type { OnChange } from "../shared.js";
11
+ import type { Meta } from "../schema.js";
12
+ import type { Control, GradientStop, GradientEasing } from "../types.js";
13
+
14
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
15
+ /** The name an easing is stored under: trimmed and lowercased when parseEasing knows it, else "linear". */
16
+ const easingName = (e: unknown): GradientEasing => (parseEasing(e) ? (e as string).trim().toLowerCase() as GradientEasing : "linear");
17
+
18
+ /** The blend progress at a position, linear between samples exactly as the browser draws the
19
+ * stops, so a stop inserted at x takes the color the ramp really shows. Null is the identity. */
20
+ function easingAt(b: Bez | null, x: number): number {
21
+ x = clamp01(x); // outside the segment is its end; never extrapolate
22
+ if (!b) return x;
23
+ const s = easingSamples(b);
24
+ for (let k = 1; k < s.length; k++) {
25
+ if (x <= s[k][0]) { const [x0, y0] = s[k - 1], [x1, y1] = s[k]; return x1 > x0 ? y0 + (y1 - y0) * (x - x0) / (x1 - x0) : y1; }
26
+ }
27
+ return 1;
28
+ }
29
+
30
+ /** A stop handle on the rail, carrying the stop it moves. */
31
+ interface StopHandle extends HTMLButtonElement { _stop?: GradientStop }
32
+
33
+ // ICON_PLUS — adapted from Hugeicons `add-01` (MIT), two paths merged. See ../../../THIRD-PARTY-NOTICES.md.
34
+ const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
35
+
36
+ // ── Gradient — a Figma-style editor: a swatch-trigger row opens a popover holding the stop bar
37
+ // over a reused color picker body; selecting a stop re-points the body at it. The bar blends in
38
+ // the space of the editor's mode, which rides along as `interpolation`, and `easing` likewise, so a
39
+ // host's `linear-gradient(in …)` through gradientCss() gets the same pixels. ──
40
+ // A value's blend space / easing: an explicit string on the object form, else null (the array
41
+ // shorthand carries none). Inspected field by field, so the normalizers take `any`.
42
+ const field = (value: any, key: string): string | null => (value && !Array.isArray(value) && typeof value[key] === "string" ? value[key] : null);
43
+ const parseInterp = (value: any) => field(value, "interpolation");
44
+ const parseEase = (value: any) => field(value, "easing");
45
+ // The easing menu; a stored cubic-bezier() not among them shows as a "Custom" option while it's the value.
46
+ const EASINGS: Array<[GradientEasing, string]> = [["linear", "Linear"], ["ease", "Ease"], ["ease-in", "Ease in"], ["ease-out", "Ease out"], ["ease-in-out", "Ease in-out"]];
47
+ function normalizeStops(value: any): GradientStop[] {
48
+ const DEF: GradientStop[] = [{ color: "oklch(0.72 0.19 25)", pos: 0 }, { color: "oklch(0.72 0.16 280)", pos: 1 }];
49
+ const arr = Array.isArray(value) ? value : (value && Array.isArray(value.stops) ? value.stops : null);
50
+ // A stop's color must be a single color token: stop text goes straight into the preview's
51
+ // `linear-gradient(…)`, where an unparsed string could splice in further layers (a url() fetch
52
+ // from persisted state). A non-finite pos → 0, clamped into [0,1]; fewer than two usable stops → the default pair.
53
+ // Trimmed as the gate trims it: the CSS tokenizer reads a non-ASCII space as part of a name.
54
+ const stop = (color: string, pos: unknown): GradientStop | null => (isColor(color) ? { color: String(color).trim(), pos: clamp(+pos || 0, 0, 1) } : null);
55
+ const out = (arr || []).map((s: any) => {
56
+ if (Array.isArray(s)) return stop(s[0], s[1]);
57
+ if (s && typeof s === "object") return stop(s.color, s.pos);
58
+ return null;
59
+ }).filter(Boolean);
60
+ return out.length >= 2 ? out : DEF;
61
+ }
62
+ function createGradient(meta: Meta, onChange: OnChange): Control {
63
+ meta.value ??= meta.stops; // the verbose form's `stops` aliases `value`
64
+ // The blend and easing read off `value: {…}` first, else the verbose form's top level, where a pasted value's fields land.
65
+ const interp0 = parseInterp(meta.value) ?? parseInterp(meta), ease0 = parseEase(meta.value) ?? parseEase(meta);
66
+ let stops = normalizeStops(meta.value);
67
+ let selStop = stops[0];
68
+ let easing = easingName(ease0); // unrecognized reads as linear
69
+
70
+ // ── Trigger row — the strip is the readout; the stop count rides along for the accessibility tree. ──
71
+ const { root, trigger, right } = triggerRow("tw-gradient", meta.label ?? "Gradient"); // ??: an explicit "" label renders none
72
+ const preview = el("span", "tw-trigger-chip tw-gradient-preview"); preview.setAttribute("role", "img");
73
+ right.append(preview);
74
+
75
+ // ── Editor popover — carries the color popover's class so it inherits its tokens and shell. ──
76
+ const pop = el("div", "tw-color-pop tw-gradient-pop");
77
+ const barRow = el("div", "tw-gradient-bar-row");
78
+ const bar = el("div", "tw-gradient-bar");
79
+ const grad = el("div", "tw-gradient-grad");
80
+ const rail = el("div", "tw-gradient-rail");
81
+ bar.append(grad, rail);
82
+ const addBtn = btn("tw-gradient-add", ICON_PLUS); addBtn.title = "Add a stop after the selected one"; addBtn.setAttribute("aria-label", "Add a color stop after the selected one");
83
+ barRow.append(bar, addBtn);
84
+
85
+ const sorted = () => [...stops].sort((a, b) => a.pos - b.pos);
86
+ // body.mode() is the single source of truth for the blend space, so the bar, preview and emitted
87
+ // interpolation can't drift. (Stops keep their own notation; CSS lets `in srgb` carry oklch() stops.)
88
+ const interp = () => modeInterpolation(body.mode());
89
+ const value = () => ({ stops: sorted().map((s) => ({ color: s.color, pos: +s.pos.toFixed(4) })), interpolation: interp(), easing });
90
+ // Both draw gradientCss(value()), so the editor can't show a ramp a host templating through it won't get.
91
+ const paint = () => { const css = gradientCss(value()); grad.style.background = css; preview.style.background = `${css}, ${CHECKER}`; };
92
+ const reflectCount = () => { preview.setAttribute("aria-label", `${stops.length} stop${stops.length === 1 ? "" : "s"}`); };
93
+ const emit = () => onChange(value());
94
+
95
+ const handleFor = (s: GradientStop) => [...(rail.children as HTMLCollectionOf<StopHandle>)].find((h) => h._stop === s); // the rail holds only stop handles
96
+ // Unlike the standalone color control, a mode switch here IS a change to the gradient's output, so it repaints and re-emits.
97
+ const body = createPickerBody({ value: selStop.color, mode: interpolationMode(interp0 || "oklch"), onMode: () => { paint(); emit(); } }, (c) => { // oklch: the documented default and colorAt's blend space
98
+ selStop.color = c;
99
+ handleFor(selStop)?.style.setProperty("--stop", c);
100
+ paint(); emit();
101
+ });
102
+ // Easing sits beside the mode: both are "how the ramp blends".
103
+ const easeSel = el("select", "tw-color-mode tw-gradient-ease"); easeSel.setAttribute("aria-label", "Easing between stops"); easeSel.title = "How the ramp blends between stops";
104
+ for (const [v, label] of EASINGS) { const o = document.createElement("option"); o.value = v; o.textContent = label; easeSel.append(o); }
105
+ const reflectEase = () => {
106
+ let custom = easeSel.querySelector<HTMLOptionElement>("option[data-custom]");
107
+ if (EASINGS.some(([v]) => v === easing)) custom?.remove();
108
+ else { if (!custom) { custom = document.createElement("option"); custom.dataset.custom = ""; custom.textContent = "Custom"; easeSel.append(custom); } custom.value = easing; }
109
+ easeSel.value = easing;
110
+ };
111
+ easeSel.addEventListener("change", () => { easing = easeSel.value as GradientEasing; reflectEase(); paint(); emit(); }); // reflect: picking a keyword retires the Custom option
112
+ body.el.querySelector(".tw-color-mode")!.after(easeSel); // after the mode select, before the gamut tag
113
+ reflectEase();
114
+ pop.append(barRow, body.el);
115
+ root.append(pop);
116
+
117
+ // A stop's accessible name carries its position and selection, the state the ring shows sighted users.
118
+ const labelStop = (h: StopHandle) => h.setAttribute("aria-label", `Color stop at ${Math.round(h._stop.pos * 100)}%${h._stop === selStop ? ", selected" : ""}`);
119
+ const reflectSel = () => { for (const h of rail.children as HTMLCollectionOf<StopHandle>) { h.dataset.sel = String(h._stop === selStop); labelStop(h); } };
120
+ // A drag or an arrow moving a stop: its spot on the rail, its name, the ramp and the value follow.
121
+ const moveStop = (h: StopHandle, pos: number) => { h._stop.pos = clamp(pos, 0, 1); h.style.left = h._stop.pos * 100 + "%"; labelStop(h); paint(); emit(); };
122
+ const renderHandles = () => {
123
+ rail.replaceChildren();
124
+ for (const s of stops) {
125
+ const h: StopHandle = btn("tw-gradient-stop"); h._stop = s;
126
+ h.style.left = s.pos * 100 + "%"; h.style.setProperty("--stop", s.color);
127
+ h.dataset.sel = String(s === selStop); labelStop(h);
128
+ // Focus is selection: tabbing onto a stop points the picker at it.
129
+ h.addEventListener("focus", () => select(s, false));
130
+ let offBar = false;
131
+ dragGesture(h, {
132
+ // preventDefault suppresses click-to-focus, so focus explicitly (ringless); that also blurs
133
+ // a dirty channel field while the old stop is still selected, so its commit lands there.
134
+ onDown: (e) => { e.preventDefault(); e.stopPropagation(); h.focus({ focusVisible: false }); select(s, false); offBar = false; },
135
+ onMove: (e) => {
136
+ // One rail rect for X and the off-bar Y test; the X math is posFromX inlined.
137
+ const r = rail.getBoundingClientRect();
138
+ moveStop(h, (e.clientX - r.left) / (r.width || 1));
139
+ // Dragging ~24px clear of the bar removes the stop (floored at two).
140
+ offBar = stops.length > 2 && (e.clientY < r.top - 24 || e.clientY > r.bottom + 24);
141
+ h.dataset.removing = String(offBar);
142
+ },
143
+ onEnd: () => { if (offBar) removeStop(s); else h.dataset.removing = "false"; },
144
+ });
145
+ h.addEventListener("dblclick", (e) => { e.preventDefault(); removeStop(s); });
146
+ // Arrows nudge by 0.01 (⇧ = 0.1); Delete stays on the popover.
147
+ h.addEventListener("keydown", (e) => {
148
+ const [d] = arrowStep(e); if (!d) return;
149
+ e.preventDefault(); moveStop(h, s.pos + d * (e.shiftKey ? 0.1 : 0.01));
150
+ });
151
+ rail.append(h);
152
+ }
153
+ };
154
+ // A drag only flips the selected flag, so the dragged handle stays live. body.set runs before
155
+ // selStop switches: re-pointing blurs a dirty channel field, whose commit must land on the old stop.
156
+ const select = (s: GradientStop, rerender: boolean) => { if (s !== selStop) { body.set(s.color); selStop = s; } rerender ? renderHandles() : reflectSel(); };
157
+
158
+ const posFromX = (x: number) => { const r = rail.getBoundingClientRect(); return clamp((x - r.left) / (r.width || 1), 0, 1); };
159
+
160
+ // A new stop's color: the bracketing stops interpolated in OKLCH at the EASED progress, so it lands invisibly on the ramp the bar draws.
161
+ const colorAt = (pos: number) => {
162
+ const ss = sorted(); let lo = ss[0], hi = ss[ss.length - 1];
163
+ for (let k = 0; k < ss.length - 1; k++) if (pos >= ss[k].pos && pos <= ss[k + 1].pos) { lo = ss[k]; hi = ss[k + 1]; break; }
164
+ const t = easingAt(parseEasing(easing), hi.pos > lo.pos ? clamp((pos - lo.pos) / (hi.pos - lo.pos), 0, 1) : 0); // a pos outside the outermost stops never extrapolates
165
+ const a = parseColor(lo.color), b = parseColor(hi.color);
166
+ // CSS missing-hue: a ~zero-chroma stop carries no hue, so the other's holds (white→red stays red).
167
+ const ach = (c: Oklcha) => c[1] < 1e-4;
168
+ let dh = b[2] - a[2]; if (dh > 180) dh -= 360; else if (dh < -180) dh += 360;
169
+ if (ach(a) || ach(b)) dh = 0;
170
+ const H = ((((ach(a) && !ach(b) ? b[2] : a[2]) + dh * t) % 360) + 360) % 360; // normalize to [0,360) so the picker reads it cleanly
171
+ return oklchStr(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, H, a[3] + (b[3] - a[3]) * t);
172
+ };
173
+ const removeStop = (s: GradientStop) => {
174
+ if (stops.length <= 2) return;
175
+ const i = stops.indexOf(s); if (i < 0) return; // a stale reference (rebuilt via set) must not splice(-1)
176
+ stops.splice(i, 1);
177
+ const next = selStop === s ? stops[Math.max(0, i - 1)] : selStop;
178
+ paint(); select(next, true); reflectCount(); emit();
179
+ handleFor(selStop)?.focus(); // keep focus on a handle so Delete can chain
180
+ };
181
+ // Insert a stop at `pos`, colored by sampling the gradient there, and select it. Shared by the +
182
+ // button (midway toward the selected stop's neighbor, so where it lands is predictable) and double-click on the bar.
183
+ const insertStopAt = (pos: number) => {
184
+ const s = { color: colorAt(pos), pos: clamp(pos, 0, 1) };
185
+ stops.push(s); paint(); select(s, true); reflectCount(); emit();
186
+ };
187
+ // Midway toward the next stop, or toward the previous from the last. There are always two stops at least (the
188
+ // normalizer's default pair, removeStop's floor), and the selected one is always among them.
189
+ const addStop = () => {
190
+ const ss = sorted(), i = ss.indexOf(selStop);
191
+ insertStopAt(i < ss.length - 1 ? (ss[i].pos + ss[i + 1].pos) / 2 : (ss[i - 1].pos + ss[i].pos) / 2);
192
+ };
193
+ addBtn.addEventListener("click", (e) => { e.stopPropagation(); addStop(); });
194
+ // Double-click, not single, so it never fights grabbing a stop; a double-click on a stop falls through to its own handler.
195
+ bar.addEventListener("dblclick", (e) => {
196
+ if ((e.target as Element).closest(".tw-gradient-stop")) return;
197
+ e.preventDefault();
198
+ insertStopAt(posFromX(e.clientX));
199
+ });
200
+
201
+ // Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset chord).
202
+ pop.tabIndex = -1;
203
+ pop.addEventListener("keydown", (e) => {
204
+ if ((e.key === "Delete" || e.key === "Backspace") && !e.altKey && !/^(input|textarea|select)$/i.test((e.target as Element).tagName) && stops.length > 2) { e.preventDefault(); removeStop(selStop); }
205
+ });
206
+
207
+ // Focus lands on the selected stop, not the first: focus is selection, so that would re-point the picker on every open.
208
+ popover(root, trigger, pop, { fallbackH: 392, onOpen: body.reflow, onReflow: body.reflow, initialFocus: () => handleFor(selStop) });
209
+
210
+ renderHandles(); paint(); reflectCount();
211
+ return {
212
+ el: root,
213
+ set: (v: unknown) => {
214
+ // Named → applied; absent → left as the user set it, never reset to OKLCH on a stops-only set.
215
+ const ip = parseInterp(v); if (ip) body.setMode(interpolationMode(ip));
216
+ const es = parseEase(v); if (es !== null) { easing = easingName(es); reflectEase(); } // same rule as the blend
217
+ const next = normalizeStops(v).sort((a, b) => a.pos - b.pos);
218
+ if (next.length === stops.length) {
219
+ // Same count (a host mirroring values back): update the stop objects in place, paired in
220
+ // position order, so a live drag keeps its handle and the picker its stop.
221
+ sorted().forEach((s, i) => {
222
+ s.color = next[i].color; s.pos = next[i].pos;
223
+ const h = handleFor(s); if (h) { h.style.left = s.pos * 100 + "%"; h.style.setProperty("--stop", s.color); }
224
+ });
225
+ } else {
226
+ const selPos = selStop.pos;
227
+ stops = next;
228
+ selStop = stops.reduce((b, s) => (Math.abs(s.pos - selPos) < Math.abs(b.pos - selPos) ? s : b), stops[0]); // re-select the nearest position, not index 0
229
+ renderHandles();
230
+ }
231
+ paint(); reflectCount(); body.set(selStop.color);
232
+ },
233
+ get: () => value(),
234
+ };
235
+ }
236
+
237
+ registerControl("gradient", createGradient);
238
+
239
+ // For test/gradient-css.test.mjs, which checks them beside the easing parser.
240
+ export { easingName, easingAt };
@@ -0,0 +1,299 @@
1
+ // ── Image — a drop zone / file pick that yields a data URL; with `options`, a gallery of supplied URLs over the upload. Lazy.
2
+ import { el, txt, btn, icon, optValue, radioButton, navIndex, popover, registerControl } from "../shared.js";
3
+ import { triggerRow } from "../heavy.js";
4
+ import type { OnChange, RadioBtn } from "../shared.js";
5
+ import type { Meta } from "../schema.js";
6
+ import type { Control } from "../types.js";
7
+
8
+ // Escape for a CSS string literal: the quote, the backslash and control characters (a raw newline ends a CSS string),
9
+ // so a stored `a.png"), url("https://x/p.png` is one broken image, not a second background layer and a fetch.
10
+ const cssString = (s: string) => String(s).replace(/[\\"]/g, "\\$&").replace(/[\x00-\x1f\x7f]/g, (c) => "\\" + c.charCodeAt(0).toString(16) + " ");
11
+ const urlCss = (s: string) => `url("${cssString(s)}")`;
12
+ const MB = 1 << 20;
13
+ const size = (n: number) => (n >= MB ? `${Math.round((n / MB) * 10) / 10} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`);
14
+ // A tile's accessible name when its option has no label: the URL's file stem; a data URL has none.
15
+ const stem = (url: string) => { if (/^data:/i.test(url)) return ""; const s = url.split(/[?#]/)[0].split("/").pop()!.replace(/\.\w+$/, ""); try { return decodeURIComponent(s); } catch { return s; } };
16
+ // Pixels, not bytes, are what make an upload heavy: a phone photo is ~4 MB as a data URL, and storage holds ~5 MB an origin,
17
+ // so one upload could keep every value from saving. A picture longer than `fit` on its long side is drawn down to it before
18
+ // it becomes the param, and one within `fit` but heavier than 1.5 MB is redrawn at its size. A JPEG stays a JPEG, at `quality`. Anything else is tried as a PNG first, so a screenshot or text
19
+ // isn't smudged, and kept as one up to 1.5 MB: a photo's PNG at 2560px is several times that, while a screenshot's is not.
20
+ // (It's weighed by size, not against the JPEG: drawn down, even a screenshot's PNG runs about twice its JPEG.) Past that it
21
+ // is a JPEG, or with transparency a WebP where the browser writes one, whichever is smaller than the PNG. An SVG has no
22
+ // pixels to fit, and a GIF, an animated WebP or an APNG would keep only its first frame, so they are taken as they are.
23
+ // Resolves "" to take the file as it is: it fits and is light, its format is kept, the browser can't decode or draw it, or
24
+ // the redraw came out no smaller.
25
+ const KEEP = /^image\/(gif|svg\+xml)$/i;
26
+ const PNG_BUDGET = 1.5 * MB;
27
+ const PNG_SIG = "\x89PNG\r\n\x1a\n";
28
+ const ascii = (b: Uint8Array, at: number, s: string) => { for (let k = 0; k < s.length; k++) if (b[at + k] !== s.charCodeAt(k)) return false; return true; };
29
+ // The file says it's animated before its first frame: WebP in VP8X's animation flag or an ANIM chunk, a PNG in an acTL
30
+ // chunk ahead of its first IDAT. Only chunk headers are read, never the frames, however many chunks come first.
31
+ export const animated = async (file: Blob) => {
32
+ const at = async (i: number, n: number) => new Uint8Array(await file.slice(i, i + n).arrayBuffer());
33
+ const h = await at(0, 21), webp = ascii(h, 0, "RIFF") && ascii(h, 8, "WEBP");
34
+ if (webp && ascii(h, 12, "VP8X") && h[20] & 2) return true;
35
+ if (!webp && !ascii(h, 0, PNG_SIG)) return false;
36
+ for (let i = webp ? 12 : 8; ; ) {
37
+ const c = await at(i, 8); if (c.length < 8) return false;
38
+ const type = String.fromCharCode(...c.subarray(webp ? 0 : 4, webp ? 4 : 8));
39
+ if (type === (webp ? "ANIM" : "acTL")) return true;
40
+ if (webp ? /^(VP8 |VP8L|ANMF)$/.test(type) : type === "IDAT") return false; // the picture has begun
41
+ const n = new DataView(c.buffer).getUint32(webp ? 4 : 0, webp); // a WebP chunk's size is little-endian and padded to even
42
+ i += webp ? 8 + n + (n & 1) : 12 + n; // at least a header on, so the end of the file comes
43
+ }
44
+ };
45
+ // A picture taken as it is keeps its bytes, and a phone's carry where it was taken: Exif's GPS, and XMP, which can repeat
46
+ // it. The value is stored and copied as JSON into source, so those blocks are cut from the bytes, losslessly: the ICC
47
+ // profile, which carries a wide gamut, and everything else stays. Exif's Orientation decides which way up the picture
48
+ // shows, so a turned one gets a bare Exif back holding only that tag. A redraw needs none of this: a canvas writes no Exif.
49
+ // Each parser hands back the bytes left, or null to keep the file whole: nothing to cut, or a block it can't read.
50
+ const orientation = (t: Uint8Array) => {
51
+ const le = ascii(t, 0, "II"), v = new DataView(t.buffer, t.byteOffset, t.byteLength); if (!le && !ascii(t, 0, "MM")) return null;
52
+ try {
53
+ const ifd = v.getUint32(4, le);
54
+ for (let k = 0, n = v.getUint16(ifd, le); k < n; k++) if (v.getUint16(ifd + 2 + k * 12, le) === 0x112) { const o = v.getUint16(ifd + 10 + k * 12, le); return o > 1 && o <= 8 ? o : 1; }
55
+ return 1;
56
+ } catch { return null; } // an offset past the end
57
+ };
58
+ // A big-endian TIFF header and one IFD with the one entry: Orientation, a SHORT.
59
+ const bareExif = (o: number) => new Uint8Array([77, 77, 0, 42, 0, 0, 0, 8, 0, 1, 1, 0x12, 0, 3, 0, 0, 0, 1, 0, o, 0, 0, 0, 0, 0, 0]);
60
+ type Bytes = Uint8Array<ArrayBuffer>; // what a Blob takes: never a SharedArrayBuffer
61
+ const crc32 = (b: Uint8Array) => { let c = ~0; for (const x of b) { c ^= x; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return ~c >>> 0; };
62
+ // An Ultra HDR JPEG's XMP carries its gain map (hdrgm:), the HDR itself, so it stays when every namespace it declares and
63
+ // every prefix it uses is the gain map's or its container's: nothing there can say where or who. Anything more, and it's cut.
64
+ const XMP = "http://ns.adobe.com/xap/1.0/\0";
65
+ const GAIN_NS = ["adobe:ns:meta/", "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "http://ns.adobe.com/hdr-gain-map/1.0/", "http://ns.google.com/photos/1.0/container/", "http://ns.google.com/photos/1.0/container/item/"];
66
+ const gainMapOnly = (b: Uint8Array) => {
67
+ const x = new TextDecoder("latin1").decode(b), ok = new Set(["xmlns", "xml"]);
68
+ if (!x.includes(GAIN_NS[2])) return false;
69
+ for (const [, p, ns] of x.matchAll(/xmlns(?::([\w.-]+))?\s*=\s*["']([^"']*)["']/g)) { if (!GAIN_NS.includes(ns)) return false; if (p) ok.add(p); }
70
+ for (const [, p] of x.matchAll(/[<\s/]([\w.-]+):[\w.-]/g)) if (!ok.has(p)) return false;
71
+ return true;
72
+ };
73
+ // An MPF JPEG (Ultra HDR's gain map, a camera's preview) indexes the images after the first by their offset from its MP
74
+ // header, so a cut between that header and the first image's end moves them all up by the bytes cut. A list that doesn't
75
+ // read is left as it was. The images after the first are left whole, their own Exif or XMP with them: the MP index and
76
+ // the gain-map XMP's Item:Length both record their sizes, so cutting into them means rewriting both, and a gain map's XMP
77
+ // is its HDR.
78
+ const mpfShift = (seg: Uint8Array, by: number) => {
79
+ const le = ascii(seg, 0, "II"), v = new DataView(seg.buffer, seg.byteOffset, seg.byteLength); if (!le && !ascii(seg, 0, "MM")) return;
80
+ try {
81
+ const ifd = v.getUint32(4, le);
82
+ for (let k = 0, n = v.getUint16(ifd, le); k < n; k++) {
83
+ const e = ifd + 2 + k * 12; if (v.getUint16(e, le) !== 0xb002) continue; // MPEntry: 16 bytes an image, its offset at 8
84
+ const list = v.getUint32(e + 8, le), count = v.getUint32(e + 4, le) >>> 4;
85
+ if (count) v.getUint32(list + count * 16 - 4, le); // the whole list is in the segment, or nothing is written
86
+ for (let j = 0; j < count; j++) { const o = list + j * 16 + 8, off = v.getUint32(o, le); if (off > by) v.setUint32(o, off - by, le); }
87
+ }
88
+ } catch { /* an offset past the segment */ }
89
+ };
90
+ const jpegCut = (b: Bytes) => {
91
+ if (b[0] !== 0xff || b[1] !== 0xd8) return null;
92
+ const out: Bytes[] = []; let i = 2, from = 0, turned = 0, mpf: Uint8Array | null = null, moved = 0;
93
+ for (;;) {
94
+ while (b[i] === 0xff && b[i + 1] === 0xff) i++; // fill bytes before a marker (T.81 B.1.1.2) stay with what's kept
95
+ const m = b[i + 1], end = i + 2 + ((b[i + 2] << 8) | b[i + 3]);
96
+ if (b[i] !== 0xff || m === 0xff || m === 1 || (m >= 0xd0 && m <= 0xd9) || end < i + 4 || end > b.length) return null; // a marker out of place
97
+ if (m === 0xda) break; // start of scan: the picture itself, kept to the end
98
+ if (m === 0xe2 && ascii(b, i + 4, "MPF\0")) mpf = b.subarray(i + 8, end);
99
+ const exif = m === 0xe1 && ascii(b, i + 4, "Exif\0");
100
+ if (exif || (m === 0xe1 && ascii(b, i + 4, "http://ns.adobe.com/x") && !(ascii(b, i + 4, XMP) && gainMapOnly(b.subarray(i + 4 + XMP.length, end))))) { // XMP, and its extension
101
+ const o = exif ? orientation(b.subarray(i + 10, end)) : 1; if (o === null) return null;
102
+ out.push(b.subarray(from, i)); from = end;
103
+ const bare = o > 1 && !turned++; if (bare) out.push(new Uint8Array([0xff, 0xe1, 0, 34, 69, 120, 105, 102, 0, 0, ...bareExif(o)]));
104
+ if (mpf) moved += end - i - (bare ? 36 : 0);
105
+ }
106
+ i = end;
107
+ }
108
+ if (mpf && moved > 0) mpfShift(mpf, moved); // in place: these are strip()'s own bytes, and they go out as parts of it
109
+ return out.length ? [...out, b.subarray(from)] : null;
110
+ };
111
+ const pngCut = (b: Bytes) => {
112
+ if (!ascii(b, 0, PNG_SIG)) return null;
113
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength), out: Bytes[] = [];
114
+ let i = 8, from = 0, turned = 0;
115
+ for (let type = ""; type !== "IEND"; ) {
116
+ const end = i + 12 > b.length ? Infinity : i + 12 + v.getUint32(i); if (end > b.length) return null;
117
+ type = String.fromCharCode(...b.subarray(i + 4, i + 8));
118
+ // A text chunk is metadata by its keyword: XMP's, or ImageMagick's raw Exif and XMP. Other text stays.
119
+ const key = /^[tiz]TXt$/.test(type) ? String.fromCharCode(...b.subarray(i + 8, Math.min(end - 4, i + 88))).split("\0")[0] : "";
120
+ if (type === "eXIf" || /^(XML:com\.adobe\.xmp|Raw profile type (exif|app1|xmp))$/i.test(key)) {
121
+ const o = type === "eXIf" ? orientation(b.subarray(i + 8, end - 4)) : 1; if (o === null) return null;
122
+ out.push(b.subarray(from, i)); from = end;
123
+ if (o > 1 && !turned++) {
124
+ const c = new Uint8Array(38), cv = new DataView(c.buffer);
125
+ cv.setUint32(0, 26); c.set([101, 88, 73, 102], 4); c.set(bareExif(o), 8); cv.setUint32(34, crc32(c.subarray(4, 34)));
126
+ out.push(c);
127
+ }
128
+ }
129
+ i = end;
130
+ }
131
+ return out.length ? [...out, b.subarray(from)] : null;
132
+ };
133
+ export const strip = async (file: File): Promise<Blob> => {
134
+ try { const b = new Uint8Array(await file.arrayBuffer()), parts = jpegCut(b) || pngCut(b); return parts ? new Blob(parts, { type: file.type }) : file; } catch { return file; }
135
+ };
136
+ const hasAlpha = (ctx: CanvasRenderingContext2D, w: number, h: number) => { const d = ctx.getImageData(0, 0, w, h).data; for (let i = 3; i < d.length; i += 4) if (d[i] < 255) return true; return false; };
137
+ // toBlob encodes off the main thread where the browser can. null when it can't write that type (it hands back a PNG instead).
138
+ const encode = (c: HTMLCanvasElement, type: string, q?: number) => new Promise<Blob | null>((r) => c.toBlob((b) => r(b && b.type === type ? b : null), type, q));
139
+ const dataUrl = (b: Blob) => new Promise<string>((r, j) => { const fr = new FileReader(); fr.onload = () => r(String(fr.result)); fr.onerror = j; fr.readAsDataURL(b); });
140
+ // A 2D context is sRGB unless asked, and a Display P3 photo (every iPhone's) drawn into it is flattened to sRGB. A P3
141
+ // canvas keeps the gamut, and toBlob tags what it writes with the P3 profile. A browser that doesn't know the option
142
+ // ignores it and hands back sRGB, as before; one that refuses the value throws, and gets the plain context.
143
+ const context = (c: HTMLCanvasElement) => { try { const ctx = c.getContext("2d", { colorSpace: "display-p3" }); if (ctx) return ctx; } catch { /* the plain context */ } return c.getContext("2d"); };
144
+ const shrink = async (file: File, fit: number, quality: number): Promise<string> => {
145
+ if (!fit || KEEP.test(file.type) || typeof Image === "undefined" || typeof URL.createObjectURL !== "function") return "";
146
+ let src = "";
147
+ try {
148
+ if (await animated(file)) return "";
149
+ src = URL.createObjectURL(file);
150
+ const img = new Image(); img.src = src; await img.decode();
151
+ const iw = img.naturalWidth, ih = img.naturalHeight, k = Math.min(1, fit / Math.max(iw, ih));
152
+ if (!(iw > 0 && ih > 0) || (k === 1 && file.size <= PNG_BUDGET)) return ""; // within fit and light: a picture already shrunk is never redrawn
153
+ const c = document.createElement("canvas"), w = (c.width = Math.max(1, Math.round(iw * k))), h = (c.height = Math.max(1, Math.round(ih * k)));
154
+ const ctx = context(c); if (!ctx) return "";
155
+ ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, w, h);
156
+ const jpeg = /^image\/jpe?g$/i.test(file.type);
157
+ let out = jpeg ? null : await encode(c, "image/png");
158
+ if (!out || out.size > PNG_BUDGET) {
159
+ const alt = await encode(c, !jpeg && hasAlpha(ctx, w, h) ? "image/webp" : "image/jpeg", quality);
160
+ if (alt && (!out || alt.size < out.size)) out = alt;
161
+ }
162
+ return out && out.size < file.size ? await dataUrl(out) : "";
163
+ } catch { return ""; } finally { if (src) URL.revokeObjectURL(src); }
164
+ };
165
+ const ICON_UPLOAD = icon('<path d="M12 15V4m-4.5 4.5L12 4l4.5 4.5M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>');
166
+
167
+ function createImage(meta: Meta, onChange: OnChange): Control {
168
+ let value = typeof meta.value === "string" ? meta.value : "";
169
+ // Options: URLs, or { value, label }; anything but a non-empty string value is skipped, and a repeated URL keeps its first.
170
+ const seen = new Set<string>();
171
+ const options = (Array.isArray(meta.options) ? meta.options : []).map((o) => ({ value: optValue(o), label: o && typeof o === "object" && o.label != null ? String(o.label) : "" })).filter((o): o is { value: string; label: string } => typeof o.value === "string" && !!o.value && !seen.has(o.value) && !!seen.add(o.value));
172
+ const canUpload = meta.upload !== false || !options.length; // the plain row is the upload: there is nothing else to leave
173
+ const canClear = meta.clear !== false; // false: a required choice, with no Clear and no Delete to empty it
174
+ const maxSize = +meta.maxSize! > 0 ? +meta.maxSize! : 10 * MB;
175
+ // A number only: `fit: true` would read as 1 and draw every upload as one pixel.
176
+ const fit = meta.fit === false || meta.fit === 0 ? 0 : typeof meta.fit === "number" && meta.fit >= 1 ? Math.round(meta.fit) : 2560; // false or 0: uploads as they are
177
+ const quality = typeof meta.quality === "number" && meta.quality > 0 && meta.quality <= 1 ? meta.quality : 0.85;
178
+ const input = el("input", "tw-image-input"); input.type = "file"; input.accept = "image/*";
179
+ // A refused file's reason takes the button text's place for a moment. It is spoken from a status region beside the
180
+ // button, not inside it: a button's children are presentational, so a live region there is never read.
181
+ const note = el("span", "tw-image-note"), live = el("span", "tw-image-live"); live.setAttribute("role", "status");
182
+ const notes = [note, live]; // a gallery adds its row's note, so a file refused on the closed row still shows why
183
+ let noteTimer: ReturnType<typeof setTimeout>;
184
+ const say = (msg: string) => { for (const n of notes) n.textContent = msg; clearTimeout(noteTimer); noteTimer = setTimeout(() => { for (const n of notes) n.textContent = ""; }, 2600); };
185
+ // An off-list value's name: its file stem, else what it is. A blob: URL's stem is a UUID, so it counts as an upload.
186
+ const customName = (v: string) => (/^blob:/i.test(v) ? "" : stem(v)) || (/^(data|blob):/i.test(v) ? "Uploaded image" : "Custom image");
187
+ let render: () => void;
188
+ // Every commit bumps gen, so a file read that lands after a tile was picked, a reset or a host set() is dropped.
189
+ let gen = 0;
190
+ const commit = (v: string, fire = true) => { gen++; value = v; render(); if (fire) onChange(value); };
191
+ const load = (file: File | undefined) => {
192
+ if (!file) return;
193
+ if (!file.type.startsWith("image/")) return say("Not an image");
194
+ if (file.size > maxSize) return say(`${size(file.size)}, max ${size(maxSize)}`);
195
+ // One commit, of the shrunk picture when there is one, else of the file as it is less its metadata: one change event, one save.
196
+ const at = ++gen;
197
+ shrink(file, fit, quality).then(async (url) => {
198
+ if (gen !== at) return;
199
+ if (url) return commit(url);
200
+ const fr = new FileReader();
201
+ fr.onload = () => { if (gen === at) commit(String(fr.result)); };
202
+ fr.onerror = () => say("Couldn't read the file");
203
+ fr.readAsDataURL(await strip(file));
204
+ });
205
+ };
206
+ // `zone` takes the drop and `over` wears [data-over] meanwhile. dragover re-asserts each tick, so crossing the children doesn't drop the outline.
207
+ // A disabled control's root stays hit-testable (only its children go inert), so the zone checks for itself; on the
208
+ // control's root, since the gallery's pane is portaled out of the panel while open.
209
+ const dropZone = (zone: HTMLElement, over: HTMLElement) => {
210
+ const off = () => !!root.closest(".is-disabled");
211
+ zone.addEventListener("dragover", (e) => { if (off()) return; e.preventDefault(); over.dataset.over = "true"; });
212
+ zone.addEventListener("dragleave", () => { over.dataset.over = "false"; });
213
+ zone.addEventListener("drop", (e) => { over.dataset.over = "false"; if (off()) return; e.preventDefault(); load(e.dataTransfer?.files[0]); });
214
+ };
215
+ input.addEventListener("change", () => { load(input.files?.[0]); input.value = ""; }); // cleared, so the same file picked again fires change
216
+ // Both forms are a trigger row with the chosen image's thumb first, as every chip row.
217
+ const row = triggerRow("tw-image", meta.label), root = row.root;
218
+ const thumb = el("span", "tw-trigger-chip tw-image-thumb");
219
+ const paintThumb = () => { thumb.dataset.set = value ? "true" : "false"; thumb.style.backgroundImage = value ? urlCss(value) : ""; };
220
+
221
+ if (!options.length) {
222
+ // The plain row: the whole trigger is the target, a click opens the file dialog and the row is also the drop zone.
223
+ row.trigger.removeAttribute("aria-expanded"); // opens a file dialog, not an expandable popover
224
+ row.trigger.setAttribute("aria-label", `${meta.label}: choose an image`);
225
+ const text = el("span", "tw-image-text");
226
+ row.right.append(thumb, note, text);
227
+ root.append(input, live);
228
+ render = () => { paintThumb(); text.textContent = value ? "Replace" : "Drop or choose"; };
229
+ row.trigger.addEventListener("click", () => input.click());
230
+ dropZone(row.trigger, row.trigger);
231
+ } else {
232
+ // The gallery: the row, with the chosen image's name, opens a pane of the tiles, as one radio group, over a foot with
233
+ // the upload button and Clear. The row names the control, so the pane doesn't repeat it. The row and the pane both
234
+ // take a dropped file.
235
+ const name = el("span", "tw-trigger-value"), rowNote = el("span", "tw-image-note");
236
+ row.right.append(thumb, rowNote, name);
237
+ notes.push(rowNote);
238
+ // The pane is the gallery, with no wrapper: its padding and the gaps between tiles and foot are its drag edge.
239
+ const pop = el("div", "tw-color-pop tw-image-pop tw-image-gallery");
240
+ const upload = canUpload ? btn("tw-button tw-image-upload", ICON_UPLOAD) : null;
241
+ const clear = txt("button", "tw-image-clear", "Clear"); clear.type = "button"; clear.setAttribute("aria-label", `${meta.label}: clear the image`);
242
+ // The foot folds like a folder: a row track holds the upload button and a column track holds Clear, so either can
243
+ // ease shut without measuring. Clear folds away when there's nothing to clear; the foot folds when it's empty.
244
+ const foot = el("div", "tw-image-foot"), footRow = el("div", "tw-image-foot-row"), slot = el("div", "tw-image-clear-slot");
245
+ foot.append(footRow); slot.append(clear);
246
+ const grid = el("div", "tw-image-grid"); grid.setAttribute("role", "radiogroup"); grid.setAttribute("aria-label", meta.label);
247
+ const n = options.length;
248
+ const cols = Math.min(6, Math.max(1, (meta.cols! | 0) || (n <= 3 ? Math.max(2, n) : n === 4 ? 2 : 3)));
249
+ grid.style.setProperty("--tw-img-cols", String(cols));
250
+ const tile = (v: string, label: string) => { const b = radioButton("tw-image-tile", v, () => commit(v)); b.textContent = ""; b.setAttribute("aria-label", label); b.style.backgroundImage = urlCss(v); return b; };
251
+ const names = options.map((o, i) => o.label || stem(o.value) || `Image ${i + 1}`);
252
+ const tiles = options.map((o, i) => { const b = tile(o.value, names[i]); grid.append(b); return b; });
253
+ // A value that is none of the options (an upload, or a host-set URL) shows as a last tile, selected. Its URL rides
254
+ // on _twVal, not data-value: the inline style already holds one copy of a data URL, and data-value would be a second.
255
+ const custom: RadioBtn = btn("tw-image-tile tw-image-custom"); custom.setAttribute("role", "radio");
256
+ const all = () => (custom.parentNode ? [...tiles, custom] : tiles); // parentNode, not isConnected: a panel not yet in the document has no connected nodes
257
+ render = () => {
258
+ const k = tiles.findIndex((b) => b._twVal === value);
259
+ if (value && k < 0) {
260
+ custom._twVal = value; custom.style.backgroundImage = urlCss(value); custom.setAttribute("aria-label", customName(value));
261
+ if (!custom.parentNode) grid.append(custom); // re-appending an attached node moves it, and a move blurs it
262
+ } else {
263
+ // The focused tile is leaving (undo, a preset, set()): focus moves on first, since removing a focused node blurs
264
+ // it to <body>, and that blur closes the pane.
265
+ if (custom.matches(":focus")) tiles[Math.max(0, k)].focus();
266
+ custom.remove();
267
+ }
268
+ const btns = all(), stop = Math.max(0, btns.findIndex((b) => b._twVal === value)); // the roving tab stop: the chosen tile, else the first
269
+ btns.forEach((b, j) => { const on = !!value && b._twVal === value; b.dataset.active = String(on); b.setAttribute("aria-checked", String(on)); b.tabIndex = j === stop ? 0 : -1; });
270
+ slot.dataset.open = String(!!value); slot.inert = !value; // nothing to clear: Clear folds away, out of the tab order
271
+ foot.dataset.open = String(!!upload || (canClear && !!value)); foot.inert = foot.dataset.open === "false"; // an empty foot folds to nothing
272
+ paintThumb();
273
+ name.textContent = !value ? "None" : k >= 0 ? names[k] : customName(value);
274
+ };
275
+ grid.addEventListener("keydown", (e) => {
276
+ const btns = all(), i = btns.indexOf(e.target as RadioBtn); if (i < 0) return;
277
+ // Focus moves before the commit: the commit can remove the focused tile (the custom one) or hide Clear.
278
+ if (canClear && (e.key === "Delete" || e.key === "Backspace") && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); tiles[0].focus(); commit(""); return; } // a modifier+Delete is the panel's reset, not a clear
279
+ const j = navIndex(e.key, i, btns.length, cols); if (j < 0) return;
280
+ e.preventDefault(); if (j !== i) { btns[j].focus(); commit(btns[j]._twVal as string); } // a tile holds its URL
281
+ });
282
+ clear.addEventListener("click", () => { (upload || tiles[0]).focus(); commit(""); }); // Clear hides itself: hand focus on first, not to the body
283
+ pop.append(grid, foot);
284
+ root.append(pop, live);
285
+ if (upload) {
286
+ upload.setAttribute("aria-label", `${meta.label}: upload an image`);
287
+ upload.append(note, txt("span", "tw-image-text", "Upload image"));
288
+ upload.addEventListener("click", () => input.click());
289
+ footRow.append(upload); root.append(input);
290
+ dropZone(row.trigger, row.trigger); dropZone(pop, pop);
291
+ }
292
+ if (canClear) footRow.append(slot);
293
+ popover(root, row.trigger, pop, { fallbackH: 220, initialFocus: () => all().find((b) => b.tabIndex === 0) });
294
+ }
295
+ render();
296
+ return { el: root, set: (v: string | null | undefined) => { if (v != null && typeof v !== "string") return; commit(v || "", false); }, get: () => value, note: say };
297
+ }
298
+
299
+ registerControl("image", createImage);