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,119 @@
1
+ /* Panel feedback chrome — the toolbar button factories (copy / reset and their one-shot
2
+ * animations), the hint tooltip and the clipboard helper. The screen-reader announcer they
3
+ * speak through lives in shared.ts, where the lazy controls reach it too. */
4
+ import { el, btn, carrySkin, placeBelow, announce } from "./shared.js";
5
+ import { ICON_COPY, ICON_CHECK, ICON_RESET, ICON_INFO } from "./icons.js";
6
+
7
+ /** A toolbar button with its one-shot animation state: timer, spin settle hook, last click time. */
8
+ interface ToolbarBtn extends HTMLButtonElement { _t?: number; _spinSettle?: () => void; _spinT?: number }
9
+
10
+ /** An icon toolbar button with matching title and aria-label. */
11
+ const toolbarBtn = (cls: string, icon: string, label: string): ToolbarBtn => { const b = btn("tw-toolbar-btn" + (cls ? " " + cls : ""), icon); b.title = label; b.setAttribute("aria-label", label); return b; };
12
+ /** Copy: puts `text(changedOnly)` on the clipboard and confirms with the check swap, and to a screen reader in words.
13
+ * `text` returns the payload and a count (-1 for the full snapshot; 0 copies nothing).
14
+ * Both handlers bail while `disabled`: a synthetic dispatchEvent(click) reaches a disabled button. */
15
+ const makeCopyBtn = (text: (changedOnly: boolean) => [string, number]) => {
16
+ const b = toolbarBtn("tw-toolbar-btn--swap", `<span class="tw-toolbar-btn__icons">${ICON_COPY}${ICON_CHECK}</span>`, "Copy values (⇧: changed only)");
17
+ b.addEventListener("click", async (e) => {
18
+ if (b.disabled) return;
19
+ const [t, n] = text(e.shiftKey);
20
+ if (!n) return announce("Nothing has changed");
21
+ if (!(await copyText(t))) return announce("Copy failed");
22
+ flashCopied(b); announce(n > 0 ? `${n} changed value${n === 1 ? "" : "s"} copied` : "Values copied");
23
+ });
24
+ return b;
25
+ };
26
+ /** Reset: runs `onReset` behind the icon's one-shot spin. */
27
+ const makeResetBtn = (onReset: () => void) => { const b = toolbarBtn("tw-toolbar-btn--reset", ICON_RESET, "Reset"); b.addEventListener("click", () => { if (b.disabled) return; spinReset(b); onReset(); }); return b; };
28
+ const flashCopied = (btn: ToolbarBtn) => { btn.classList.add("is-copied"); clearTimeout(btn._t); btn._t = setTimeout(() => btn.classList.remove("is-copied"), 1400); };
29
+ /** Reset spin — an accumulated rotation on --tw-spin driven by the transform transition, so a
30
+ * second click mid-spin retargets smoothly instead of snapping back. Once settled the counter
31
+ * renormalizes to 0 with the transition suppressed (−n·360° is the same angle). "Settled" is the
32
+ * transform's own transitionend, not a timer: the hover wind-up shares the transform and restarts
33
+ * the clock. An end <250ms after the click is a stale wind settle; the timeout is only a fallback
34
+ * for when no transition runs at all (reduced motion, hidden panel). */
35
+ const spinReset = (btn: ToolbarBtn) => {
36
+ const svg = btn.querySelector("svg");
37
+ if (!btn._spinSettle) {
38
+ btn._spinSettle = () => {
39
+ clearTimeout(btn._t);
40
+ btn.classList.remove("is-spinning");
41
+ svg.style.transition = "none"; svg.style.setProperty("--tw-spin", "0deg");
42
+ // getBoundingClientRect, not offsetWidth: on an SVG element offsetWidth is undefined, so the
43
+ // reflow idiom flushed nothing and the −n·360° → 0 change animated as a visible unwind.
44
+ void svg.getBoundingClientRect();
45
+ svg.style.transition = "";
46
+ };
47
+ svg.addEventListener("transitionend", (e) => {
48
+ if (e.propertyName === "transform" && btn.classList.contains("is-spinning") && performance.now() - btn._spinT > 250) btn._spinSettle();
49
+ });
50
+ }
51
+ btn._spinT = performance.now();
52
+ svg.style.setProperty("--tw-spin", `${(parseFloat(svg.style.getPropertyValue("--tw-spin")) || 0) - 360}deg`);
53
+ btn.classList.add("is-spinning");
54
+ clearTimeout(btn._t);
55
+ const fallback = () => { if (svg.getAnimations && svg.getAnimations().length) btn._t = setTimeout(fallback, 250); else btn._spinSettle(); };
56
+ btn._t = setTimeout(fallback, 600);
57
+ };
58
+
59
+ // Hint tooltip — one shared bubble, portaled to <body> so it clears the panel's overflow clip.
60
+ let hintTip: HTMLDivElement | null = null, hintTimer = 0, hintAnchor: Element | null = null;
61
+ const onHintKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); hideHintNow(); } }; // bound only while open; WCAG 1.4.13 (dismissable)
62
+ /** Hide the tip now; with `owner` (panel.destroy()), only a tip anchored inside it — the node is shared. */
63
+ const hideHintNow = (owner?: Element) => { if (owner && hintAnchor && !owner.contains(hintAnchor)) return; clearTimeout(hintTimer); document.removeEventListener("keydown", onHintKey); if (hintTip) hintTip.classList.remove("is-open"); };
64
+ function showHint(anchor: Element, text: string) {
65
+ if (!hintTip) { hintTip = el("div", "tw-tip tw-portal"); hintTip.setAttribute("role", "tooltip"); }
66
+ if (!hintTip.isConnected) document.body.appendChild(hintTip); // re-home after a host replaced <body>'s content
67
+ clearTimeout(hintTimer);
68
+ hintTip.textContent = text;
69
+ carrySkin(hintTip, anchor); // resolved at show time: setTheme may have run since build
70
+ const wasOpen = hintTip.classList.contains("is-open");
71
+ hintTip.style.visibility = "hidden"; hintTip.classList.add("is-open");
72
+ placeBelow(anchor, hintTip, { align: "center", prefer: "above", gap: 8 });
73
+ hintTip.style.visibility = "";
74
+ document.addEventListener("keydown", onHintKey);
75
+ hintAnchor = anchor;
76
+ // Unmount watchdog: a host removing the panel mid-hover would strand the open tip. One rAF per frame while open.
77
+ if (!wasOpen) requestAnimationFrame(function watch() { if (!hintTip.classList.contains("is-open")) return; if (!hintAnchor.isConnected) return hideHintNow(); requestAnimationFrame(watch); });
78
+ }
79
+ function hideHint() { if (hintTip) { clearTimeout(hintTimer); hintTimer = setTimeout(hideHintNow, 80); } } // a short grace, so crossing marker → tip doesn't flicker
80
+ /** Every control's label element — the one list behind the hint marker's home and the per-control
81
+ * reset target (panel.ts wireReset), so the two can't drift. */
82
+ const LABEL_SEL = ".tw-slider-label, .tw-row-label, .tw-select-label, .tw-trigger-label, .tw-radiogrid-label, .tw-field-label, .tw-folder-title, .tw-fps-label, .tw-plot-label, .tw-spring-label, .tw-bezier-label";
83
+ /** A control's `hint` becomes a ⓘ marker beside its label that reveals the tooltip on hover/focus. */
84
+ function addHintMarker(node: Element, hint: string) {
85
+ const label = node.querySelector(LABEL_SEL) || node;
86
+ if (label === node && node.classList.contains("tw-separator")) return; // a divider has no label to carry a marker
87
+ // Inside a <button> (select trigger, folder header), which can't contain interactive content, the
88
+ // ⓘ is a decorative span and the hint rides the host button's aria-description for screen readers.
89
+ const hostBtn = label.closest("button");
90
+ const mark = hostBtn ? el("span", "tw-hint", ICON_INFO) : btn("tw-hint", ICON_INFO);
91
+ const show = () => showHint(mark, hint);
92
+ if (hostBtn) {
93
+ mark.setAttribute("aria-hidden", "true");
94
+ const prior = hostBtn.getAttribute("aria-description");
95
+ hostBtn.setAttribute("aria-description", prior ? `${prior}. ${hint}` : hint);
96
+ } else {
97
+ mark.setAttribute("aria-label", hint);
98
+ mark.addEventListener("focus", show);
99
+ mark.addEventListener("blur", hideHint);
100
+ mark.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }); // keyboard activation stays on the marker
101
+ }
102
+ mark.addEventListener("pointerenter", show);
103
+ mark.addEventListener("pointerleave", hideHint);
104
+ mark.addEventListener("pointerdown", (e) => e.stopPropagation()); // mustn't start a slider scrub or panel drag
105
+ mark.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); }); // and (inside a control button) mustn't toggle the parent
106
+ label.appendChild(mark);
107
+ }
108
+ async function copyText(text: string) {
109
+ try { await navigator.clipboard.writeText(text); return true; } catch {}
110
+ // Fallback when the clipboard API is blocked (no user activation): textarea + execCommand.
111
+ const ta = document.createElement("textarea");
112
+ ta.value = text; ta.style.position = "fixed"; ta.style.top = "-9999px";
113
+ document.body.appendChild(ta); ta.focus(); ta.select();
114
+ let ok = false; try { ok = document.execCommand("copy"); } catch {}
115
+ document.body.removeChild(ta);
116
+ return ok;
117
+ }
118
+
119
+ export { toolbarBtn, makeCopyBtn, makeResetBtn, flashCopied, copyText, spinReset, announce, hideHintNow, addHintMarker, LABEL_SEL };
@@ -0,0 +1,21 @@
1
+ /* The fold a row behind a render condition hides and shows with. A panel with conditionals
2
+ * imports it once its first pass is done, so a basic panel never fetches it.
3
+ * The rows below close up over the row's height and gap as it's clipped from the bottom and
4
+ * fades; its own height is held so nothing inside squashes, and flipped mid-fold it turns
5
+ * around from where it's drawn. */
6
+ import { REDUCE_MOTION } from "./shared.js";
7
+
8
+ /** Fold `node` shut or open; reduced motion, no Animations API or a row with nothing drawn just toggle the class. */
9
+ export const fold = (node: HTMLElement & { _fold?: Animation }, hide: boolean) => {
10
+ const cs = getComputedStyle(node), at = node._fold?.playState == "running" && { marginBottom: cs.marginBottom, clipPath: cs.clipPath, opacity: cs.opacity };
11
+ node._fold?.cancel(); node.classList.remove("tw-cond-hidden");
12
+ const h = !REDUCE_MOTION.matches && node.animate && node.offsetHeight;
13
+ node.classList.toggle("tw-cond-hidden", hide);
14
+ if (h) try {
15
+ // d is the room the row takes: its height, the 6px stack gap (tweaks.css) and its own top margin.
16
+ const d = h + 6 + parseFloat(cs.marginTop), H = h + "px", shut = { height: H, marginBottom: -d + "px", clipPath: `inset(0 0 ${d}px)`, opacity: 0 }, open = { height: H, clipPath: "inset(0)" };
17
+ (node._fold = node.animate([at || (hide ? open : shut), hide ? shut : open], { duration: 350, easing: cs.getPropertyValue("--tw-ease-fold").trim() }))
18
+ .onfinish = () => node.classList.toggle("tw-cond-hidden", hide);
19
+ node.classList.remove("tw-cond-hidden"); // after animate(): a refused easing leaves the toggle done
20
+ } catch {}
21
+ };
@@ -0,0 +1,302 @@
1
+ /* Tweaks — what only the lazy controls use: the numeric field engine and its scrub, the
2
+ * press-drag gesture, the SVG factory, canvas sizing and the modal-trigger row, kept out of
3
+ * the shared chunk. It also registers the Number control, which no shorthand infers. */
4
+ import { el, btn, txt, clamp, carrySkin, quietFocus, stepPrecision, gridEnds, roundToStep, icon, registerControl, announce, sayNow } from "./shared.js";
5
+ import type { Built, NumSpec, NumField, OnChange } from "./shared.js";
6
+ import type { Meta } from "./schema.js";
7
+
8
+ const svgNS = "http://www.w3.org/2000/svg";
9
+ // The SVG twin of shared.ts's el().
10
+ const svgEl = <K extends keyof SVGElementTagNameMap>(tag: K, cls?: string): Built<SVGElementTagNameMap[K]> => { const n = document.createElementNS(svgNS, tag); if (cls) n.setAttribute("class", cls); return n; };
11
+ // A token reads back as its light-dark(light, dark) pair, which a canvas can't parse: keep the arm the node's
12
+ // color-scheme paints with ("light dark" follows the OS), innermost pairs included, as CSS would.
13
+ const pickScheme = (v: string, cs: CSSStyleDeclaration) => {
14
+ if (!/light-dark\(/i.test(v)) return v;
15
+ const s = cs.colorScheme || "", dark = /\bdark\b/.test(s) && (!/\blight\b/.test(s) || matchMedia("(prefers-color-scheme: dark)").matches);
16
+ for (let i; (i = v.search(/light-dark\(/i)) !== -1;) {
17
+ let depth = 0, comma = -1, j = i + 11;
18
+ for (; j < v.length; j++) { const c = v[j]; if (c === "(") depth++; else if (c === ")") { if (!depth) break; depth--; } else if (c === "," && !depth && comma < 0) comma = j; }
19
+ if (comma < 0 || j >= v.length) return ""; // malformed: the caller's fallback color
20
+ v = v.slice(0, i) + (dark ? v.slice(comma + 1, j) : v.slice(i + 11, comma)).trim() + v.slice(j + 1);
21
+ }
22
+ return v;
23
+ };
24
+ const cssVar = (node: Element, name: string) => { const r = getComputedStyle(node); return pickScheme(r.getPropertyValue(name).trim(), r); };
25
+ // One getComputedStyle for both reads — this runs in the FPS draw loop.
26
+ const accentColor = (node: Element) => { const r = getComputedStyle(node); const v = (n: string) => pickScheme(r.getPropertyValue(n).trim(), r); return v("--tw-accent") || v("--tw-text-primary") || "#fff"; };
27
+ // Select the whole value on focus (the hex field: you type a new color, never edit a digit).
28
+ // Browsers collapse a focus-time selection on the click's own mouseup, so the first primary
29
+ // mouseup after a pointer-initiated focus is swallowed while the whole value is still selected.
30
+ const selectAllOnFocus = (input: HTMLInputElement) => {
31
+ let swallowUp = false;
32
+ input.addEventListener("pointerdown", (e) => { swallowUp = e.button === 0 && document.activeElement !== input; });
33
+ input.addEventListener("focus", () => input.select());
34
+ input.addEventListener("mouseup", (e) => {
35
+ if (!swallowUp) return;
36
+ swallowUp = false;
37
+ if (e.button === 0 && document.activeElement === input && input.selectionStart === 0 && input.selectionEnd === input.value.length) e.preventDefault();
38
+ });
39
+ };
40
+ // Escape in an edited field restores `kept` and stops; otherwise it passes on and closes the pane. Without this,
41
+ // closing blurred the field and committed the half-typed value.
42
+ const escapeReverts = (input: HTMLInputElement, kept: () => string) => input.addEventListener("keydown", (e: KeyboardEvent) => {
43
+ if (e.key !== "Escape" || input.value === kept()) return;
44
+ e.preventDefault(); e.stopPropagation(); input.value = kept();
45
+ });
46
+ // Drag a pane by its edge (padding, or a gap between parts) to uncover what's beneath; it keeps that offset from its row
47
+ // while open (st.moved), and the next open resets it. Bound by popover() on a movable pane's first open.
48
+ const dragPane = (pop: HTMLElement, st: { open: boolean; moved: boolean }, place: () => void) => pop.addEventListener("pointerdown", (e: PointerEvent) => {
49
+ if (e.target !== pop || e.button !== 0 || !st.open) return;
50
+ e.preventDefault(); // no text selection, and focus stays where it was
51
+ const id = e.pointerId, sx = e.clientX, sy = e.clientY, ox = parseFloat(pop.style.left), oy = parseFloat(pop.style.top), wasMoved = st.moved;
52
+ let going = false;
53
+ try { pop.setPointerCapture(id); } catch {}
54
+ const move = (m: PointerEvent) => {
55
+ if (m.pointerId !== id) return;
56
+ if (!going) { if (Math.hypot(m.clientX - sx, m.clientY - sy) < 4) return; going = st.moved = true; pop.classList.add("is-moving"); }
57
+ pop.style.left = ox + m.clientX - sx + "px"; pop.style.top = oy + m.clientY - sy + "px"; place();
58
+ };
59
+ const end = (m: PointerEvent) => { if (m.pointerId === id) stop(); };
60
+ // Escape mid-move puts it back and keeps it open. Capture phase, so it beats the pane's own Escape.
61
+ const key = (k: KeyboardEvent) => {
62
+ if (k.key !== "Escape" || !going) return;
63
+ k.preventDefault(); k.stopPropagation();
64
+ pop.style.left = ox + "px"; pop.style.top = oy + "px"; st.moved = wasMoved; place(); stop();
65
+ try { pop.releasePointerCapture(id); } catch {}
66
+ };
67
+ const stop = () => {
68
+ pop.removeEventListener("pointermove", move); pop.removeEventListener("pointerup", end); pop.removeEventListener("pointercancel", end); pop.removeEventListener("lostpointercapture", end);
69
+ document.removeEventListener("keydown", key, true); pop.classList.remove("is-moving");
70
+ };
71
+ pop.addEventListener("pointermove", move); pop.addEventListener("pointerup", end); pop.addEventListener("pointercancel", end); pop.addEventListener("lostpointercapture", end); // lost: the node left mid-press
72
+ document.addEventListener("keydown", key, true);
73
+ });
74
+ // Press-drag on a node: onDown on pointerdown (pointer captured), onMove per move, onEnd on
75
+ // up/cancel/lost capture or when the button releases off the node (buttons===0).
76
+ function dragGesture(node: HTMLElement, { onDown, onMove, onEnd }: { onDown?: (e: PointerEvent) => void; onMove?: (e: PointerEvent) => void; onEnd?: (e: PointerEvent) => void } = {}) {
77
+ let activeId: number | null = null; // one captured pointer; a second finger can't fork the drag
78
+ const end = (e: PointerEvent) => { if (activeId === null || e.pointerId !== activeId) return; activeId = null; onEnd && onEnd(e); };
79
+ node.addEventListener("pointerdown", (e) => { if (e.button !== 0 || activeId !== null) return; activeId = e.pointerId; try { node.setPointerCapture(e.pointerId); } catch {} onDown && onDown(e); });
80
+ node.addEventListener("pointermove", (e) => { if (e.pointerId !== activeId) return; if (e.buttons === 0) return end(e); onMove && onMove(e); });
81
+ node.addEventListener("pointerup", end); node.addEventListener("pointercancel", end);
82
+ node.addEventListener("lostpointercapture", end); // capture lost mid-drag (popover unmounting) ends it too
83
+ }
84
+ // Press-drag flagging .is-grabbing on the surface for the gesture's run (the thumb-lift CSS keys off it).
85
+ const grabSurface = (surface: HTMLElement, set: (e: PointerEvent) => void) => dragGesture(surface, {
86
+ onDown: (e) => { surface.classList.add("is-grabbing"); set(e); },
87
+ onMove: set,
88
+ onEnd: () => surface.classList.remove("is-grabbing"),
89
+ });
90
+ // Pointer position inside a box as [x, y] fractions in 0–1.
91
+ const boxFrac = (e: MouseEvent, box: Element): [number, number] => { const r = box.getBoundingClientRect(); return [clamp((e.clientX - r.left) / r.width, 0, 1), clamp((e.clientY - r.top) / r.height, 0, 1)]; };
92
+ // An arrow key as a [dx, dy] step, ↑ positive as on screen; [0, 0] for any other key.
93
+ const arrowStep = (e: KeyboardEvent): [number, number] => [e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0, e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0];
94
+ // A 2D surface's readout rides on aria-description, which a screen reader speaks on focus and never again: the returned
95
+ // function says it once a run of keys stops.
96
+ const sayWhenKeysStop = (node: HTMLElement) => { let t = 0; return () => { clearTimeout(t); t = setTimeout(() => { if (node.isConnected) announce(node.getAttribute("aria-description") || ""); }, 250); }; };
97
+ // Size a canvas to its CSS box × devicePixelRatio (capped) and scale the context to CSS pixels; returns [cssW, cssH].
98
+ const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, maxDpr = Infinity): [number, number] => {
99
+ const r = canvas.getBoundingClientRect();
100
+ const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
101
+ canvas.width = r.width * dpr; canvas.height = r.height * dpr;
102
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
103
+ return [r.width, r.height];
104
+ };
105
+ // The modal-trigger row (color, gradient, point): a row button, label left, a preview cluster (`right`) the caller fills.
106
+ const triggerRow = (cls: string, label: string) => {
107
+ const root = el("div", cls);
108
+ const trigger = btn("tw-trigger"); trigger.setAttribute("aria-expanded", "false");
109
+ const right = el("span", "tw-trigger-right");
110
+ trigger.append(txt("span", "tw-trigger-label", label), right);
111
+ root.append(trigger);
112
+ return { root, trigger, right };
113
+ };
114
+
115
+ // A 2-bar drag handle (the icon sets' grips are dots).
116
+ const ICON_GRIP = icon('<path d="M6 4v8M10 4v8"/>', "", 2, 16); // 1.5px bars at the 12px it is drawn at
117
+
118
+ // Grab guide — a dotted line from the grab point to the cursor plus a value bubble, portaled
119
+ // to <body> for a drag. Its four spans (line, dot, arrow, bubble) are addressed by index.
120
+ interface GuideEl extends HTMLDivElement { readonly children: HTMLCollectionOf<HTMLSpanElement> }
121
+ function makeGrabGuide() {
122
+ let g: GuideEl | null = null, y = 0, x0 = 0, bx = 0;
123
+ return {
124
+ show(x: number, atY: number, bubbleX: number | undefined, anchor: Element) {
125
+ g = el("div", "tw-grab-guide tw-portal") as GuideEl;
126
+ g.innerHTML = `<span class="tw-grab-line"></span><span class="tw-grab-dot"></span><span class="tw-grab-arrow"></span><span class="tw-grab-bubble"></span>`;
127
+ y = atY; x0 = x; bx = bubbleX ?? x; // bubble anchors over the field center, not the cursor
128
+ g.children[1].style.cssText = `left:${x}px;top:${y}px`;
129
+ carrySkin(g, anchor); // the anchor panel's theme + scheme, like every portal
130
+ document.body.appendChild(g);
131
+ },
132
+ move(x: number, text: string) {
133
+ if (!g) return;
134
+ g.children[0].style.cssText = `left:${Math.min(x0, x)}px;top:${y}px;width:${Math.abs(x - x0)}px`;
135
+ g.children[2].style.cssText = `left:${x}px;top:${y}px;transform:translate(-50%,-50%) scaleX(${x >= x0 ? 1 : -1})`; // arrowhead at the cursor, pointing in the drag direction
136
+ g.children[3].style.cssText = `left:${bx}px;top:${y - 16}px`; g.children[3].textContent = text;
137
+ },
138
+ hide() { if (g) { g.remove(); g = null; } },
139
+ };
140
+ }
141
+
142
+ // Drag-to-scrub on a grab handle: 1px ≈ one step (Shift ×10, Alt ×0.1), re-anchoring on a
143
+ // modifier change so the value never jumps. read() is the live value, text() the bubble label.
144
+ function attachScrub(grab: HTMLElement, wrap: HTMLElement, step: number, read: () => number, apply: (v: number) => void, text: () => string) {
145
+ let downX = 0, downV = 0, curK = 1; const gd = makeGrabGuide();
146
+ // Every end path matters: capture lost mid-scrub must still hide the full-screen guide.
147
+ dragGesture(grab, {
148
+ onDown: (e) => { e.preventDefault(); downX = e.clientX; downV = read(); curK = 1; grab.classList.add("is-dragging"); const br = wrap.getBoundingClientRect(); gd.show(e.clientX, br.top + br.height / 2, br.left + br.width / 2, wrap); gd.move(e.clientX, text()); },
149
+ onMove: (e) => { const k = e.shiftKey ? 10 : e.altKey ? 0.1 : 1; if (k !== curK) { curK = k; downX = e.clientX; downV = read(); } apply(downV + (e.clientX - downX) * step * k); gd.move(e.clientX, text()); },
150
+ onEnd: () => { grab.classList.remove("is-dragging"); gd.hide(); },
151
+ });
152
+ }
153
+
154
+ // ── The labeled numeric field — ONE numeric engine: a sanitized step, min-anchored
155
+ // round-to-step, optional `soft`, a text input committing on change/Enter, and the grab
156
+ // handle. Two chromes: the boxed field, and with `spec.row` the row that IS the Number control. ──
157
+ function numField(spec: NumSpec, onChange?: (v: number) => void): NumField {
158
+ // A 0/negative/non-finite step breaks round-to-step (reachable via a point component's step).
159
+ const step = Number.isFinite(+spec.step) && +spec.step > 0 ? +spec.step : 1;
160
+ const bound = (b: unknown) => (b == null ? NaN : +b); // null → NaN → unbounded (a null max coerced to 0)
161
+ let min = bound(spec.min), max = bound(spec.max);
162
+ if (Number.isFinite(min) && Number.isFinite(max) && max < min) { notice("swap", [spec.label ?? "number", min, max]); const t = min; min = max; max = t; } // an inverted pair would clamp every value to one end
163
+ const anchor = Number.isFinite(min) ? min : 0, decimals = stepPrecision(step); // min-anchored, like the slider
164
+ const [lo, hi] = gridEnds(Number.isFinite(min) ? min : -Infinity, Number.isFinite(max) ? max : Infinity, step, anchor); // on-grid ends, so fit(fit(x)) === fit(x)
165
+ const fit = (val: number) => { const n = roundToStep(val, anchor, step); return spec.soft ? n : clamp(n, lo, hi); };
166
+ let value = fit(Number.isFinite(+spec.value) ? +spec.value : 0);
167
+ const root = el("div", spec.row ? "tw-row" : "tw-field");
168
+ const wrap = el("div", "tw-num-wrap");
169
+ const grab = el("span", "tw-num-grab", ICON_GRIP); grab.setAttribute("aria-hidden", "true"); grab.title = "Drag to adjust";
170
+ const inp = el("input", "tw-num"); inp.type = "text"; inp.inputMode = "decimal"; inp.setAttribute("aria-label", spec.unit ? `${spec.label} (${spec.unit})` : spec.label); inp.value = value.toFixed(decimals);
171
+ quietFocus(inp); // click-to-edit stays ringless; Tab rings
172
+ wrap.append(grab, inp); if (spec.unit) wrap.append(txt("span", "tw-num-unit", spec.unit)); root.append(txt("span", spec.row ? "tw-row-label" : "tw-field-label", spec.label), wrap);
173
+ const set = (val: number | string, fire = true) => { val = +val; if (!Number.isFinite(val)) return; value = fit(val); inp.value = value.toFixed(decimals); if (fire && onChange) onChange(value); };
174
+ // Typed text is read strictly as a whole number, as the slider's editor reads it (not parseFloat, which takes "1,000"
175
+ // as 1 and "12abc" as 12); the field's unit may be typed along. NaN for anything else.
176
+ const typed = () => Number(inp.value.replace(spec.unit || "", "").trim() || NaN);
177
+ // Only a readable number that lands on a new value commits; otherwise the field shows the kept value again and fires nothing.
178
+ inp.addEventListener("change", () => { const n = typed(); if (Number.isFinite(n) && fit(n) !== value) set(n); else inp.value = value.toFixed(decimals); });
179
+ escapeReverts(inp, () => value.toFixed(decimals));
180
+ // ↑/↓ step off the half-typed value (a typed "4" then ↑ reads 5), the keyboard twin of the scrub.
181
+ inp.addEventListener("keydown", (e) => {
182
+ if (e.key === "Enter") return inp.blur();
183
+ const d = e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0; if (!d) return;
184
+ e.preventDefault();
185
+ const n = typed();
186
+ set((Number.isFinite(n) ? n : value) + d * step * (e.shiftKey ? 10 : 1));
187
+ });
188
+ attachScrub(grab, wrap, step, () => value, set, () => inp.value);
189
+ return { el: root, set: (val: number | string) => set(val, false), get: () => value, range: [lo, hi] };
190
+ }
191
+
192
+ // ── Number — numField in its row chrome. ──
193
+ // The coarsest power of ten a number sits on (0.5 → 0.1, 3.14159 → 0.00001, 1230 → 10), read at 12 significant digits so
194
+ // 0.1 + 0.2 counts as 0.3; Infinity for 0 or a non-number, which constrains nothing. A step no coarser keeps it exactly.
195
+ // Past eight significant digits a number was computed, not written (1/3, Math.PI): it steps at its fourth digit, as a slider
196
+ // rounds it, so an arrow moves it by 0.0001, not by 1e-12.
197
+ const decimalStep = (v: unknown) => {
198
+ const n = Number(Math.abs(+(v as number)).toPrecision(12)); if (!n || !Number.isFinite(n)) return Infinity;
199
+ const d = stepPrecision(n), e = Math.floor(Math.log10(n)); if (d) return Number(`1e${e + d + 1 > 8 ? e - 3 : -d}`);
200
+ let k = 0; while (k < 15 && n % 10 ** (k + 1) === 0) k++;
201
+ return 10 ** k;
202
+ };
203
+ // A number with no step takes 1, or finer when its default or min has decimals (0.5 steps by 0.5): decided here, in the
204
+ // lazy chunk, so the basic panel carries none of it.
205
+ registerControl("number", (meta: Meta, onChange?: OnChange) => numField({ ...meta, step: meta.step ?? Math.min(1, decimalStep(meta.value), decimalStep(meta.min)), row: true }, onChange));
206
+
207
+ // ── Spring step response — shared by the spring and motion controls. The window runs to the
208
+ // settle, capped at 2.2 s. ──
209
+ // Motion's visualDuration + bounce, resolved as motion-dom's spring generator does (mass 1): a bounce maps to a damping ratio
210
+ // as SwiftUI's does, floored at 0.05 either way; the period is visualDuration × 1.2; and an overdamped (negative) bounce
211
+ // scales the frequency by a Newton solve so it arrives when the critically damped spring would. Copied value for value, so
212
+ // what's tuned here plays the same in Motion.
213
+ const CRIT = (2 * Math.PI) / 1.2;
214
+ // The visualDuration (s) the spring editor shows and resolves: down to 10 ms, up to the 10 s a spring's linear() runs.
215
+ const SPRING_DUR = [0.01, 10] as const;
216
+ const bounceRatio = (b: number) => (b < 0 ? 1 / Math.max(1 + b, 0.05) : Math.max(1 - b, 0.05));
217
+ const overdampedScale = (z: number) => {
218
+ if (!(z > 1)) return 1;
219
+ const r = Math.sqrt(z * z - 1), slow = z - r, fast = z + r, target = 2 * r * Math.exp(-CRIT) * (1 + CRIT);
220
+ let x = CRIT;
221
+ for (let i = 1; i < 12; i++) x -= (fast * Math.exp(-slow * x) - slow * Math.exp(-fast * x) - target) / (Math.exp(-fast * x) - Math.exp(-slow * x));
222
+ return x / CRIT;
223
+ };
224
+ const springFromTime = (visualDuration: number, bounce: number) => {
225
+ const z = bounceRatio(bounce), root = (CRIT / visualDuration) * overdampedScale(z), k = root * root;
226
+ return { stiffness: k, damping: 2 * z * Math.sqrt(k), mass: 1 };
227
+ };
228
+ // Its inverse, for physics keys handed to a time-mode editor: the visualDuration + bounce Motion would resolve to them.
229
+ const timeFromSpring = (k: number, d: number, m = 1) => {
230
+ const z = d / (2 * Math.sqrt(k * m));
231
+ return { visualDuration: (CRIT * overdampedScale(z)) / Math.sqrt(k / m), bounce: z > 1 ? 1 / z - 1 : 1 - z };
232
+ };
233
+ // How fast the slowest mode dies out: ζω₀, or an overdamped spring's slow root (ζω₀ is its fast one, so the preview
234
+ // window shrank to milliseconds and drew a near-flat line).
235
+ const decayRate = (w0: number, z: number) => (z > 1 ? w0 * (z - Math.sqrt(z * z - 1)) : z * w0);
236
+ const springSpan = (k: number, d: number, m: number) => Math.min(2.2, 9 / Math.max(decayRate(Math.sqrt(k / m), d / (2 * Math.sqrt(k * m))), 0.5));
237
+ // Closed-form step response of a damped harmonic oscillator (under/critical/over): position at time t.
238
+ const springAt = (k: number, d: number, m: number) => {
239
+ const w0 = Math.sqrt(k / m), z = d / (2 * Math.sqrt(k * m));
240
+ if (z < 1 - 1e-4) { const wd = w0 * Math.sqrt(1 - z * z); return (t: number) => 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + (z * w0 / wd) * Math.sin(wd * t)); }
241
+ if (z <= 1 + 1e-4) return (t: number) => 1 - Math.exp(-w0 * t) * (1 + w0 * t);
242
+ const s = Math.sqrt(z * z - 1), a = w0 * (z + s), b = w0 * (z - s);
243
+ return (t: number) => 1 - (a * Math.exp(-b * t) - b * Math.exp(-a * t)) / (a - b);
244
+ };
245
+ // N samples over the span, for drawing.
246
+ function springCurve(k: number, d: number, m: number, N = 64, T = springSpan(k, d, m)) {
247
+ const x = springAt(k, d, m), out: number[] = [];
248
+ for (let i = 0; i < N; i++) out.push(x((i / (N - 1)) * T));
249
+ return out;
250
+ }
251
+ // The run as a CSS transition: how long, and a linear() easing. It lasts until within e⁻⁷ of rest, by the slowest mode
252
+ // (an overdamped spring's slow root, not ζω₀), capped at 10 s, where the last stop is 1 so it ends at rest. Stops were
253
+ // spaced by duration alone, so a bouncy spring got three a wobble and missed its peaks by a third; now there are at least
254
+ // 24 a wobble plus one on every peak and trough, each at its own percentage, so the overshoots land where they are.
255
+ const springRun = (k: number, d: number, m: number) => {
256
+ const w0 = Math.sqrt(k / m), z = d / (2 * Math.sqrt(k * m)), x = springAt(k, d, m);
257
+ const span = Math.min(7 / Math.max(decayRate(w0, z), 1e-3), 10);
258
+ const wd = z < 1 ? w0 * Math.sqrt(1 - z * z) : 0, n = Math.min(Math.max(48, Math.ceil(wd ? (24 * span * wd) / (2 * Math.PI) : span * 40)), 800);
259
+ const ts = Array.from({ length: n }, (_, i) => (i / (n - 1)) * span);
260
+ if (wd) for (let t = Math.PI / wd; t < span; t += Math.PI / wd) ts.push(t); // every extremum of the underdamped response
261
+ ts.sort((a, b) => a - b);
262
+ const stops = ts.filter((t, i) => i === 0 || t - ts[i - 1] > 1e-6).map((t, i, a) => `${i === a.length - 1 ? 1 : +x(t).toFixed(3)} ${+((t / span) * 100).toFixed(2)}%`);
263
+ return { duration: Math.round(span * 1000), easing: `linear(${stops.join(", ")})` };
264
+ };
265
+
266
+ // The name a typo is a single edit from (a letter added, dropped or changed). It and the notices below are lazy, as
267
+ // only a mistake needs them. unknownType: a schema or markup `type` no control answers to. Its list holds the aliases
268
+ // (toggle, bezier) and not the color module's picker body, which is registered as a control but isn't a type to write.
269
+ const nearest = (s: string, names: string[]) => {
270
+ const a = s.toLowerCase(); // case-blind, so "textmuted" finds "textMuted"
271
+ return names.find((n) => { const k = n.toLowerCase(); let i = 0; while (i < a.length && a[i] === k[i]) i++; return [[1, 1], [0, 1], [1, 0]].some(([x, y]) => a.slice(i + x) === k.slice(i + y)); });
272
+ };
273
+ const unknownType = (where: string, type: string, known: string[], then: string) => {
274
+ const names = [...new Set(known)].filter((t) => t !== "folder" && t !== "colorpicker").sort(), near = nearest(type, names);
275
+ console.warn(`[tweaks] ${where}: "${type}" isn't a control type${near ? ` — did you mean "${near}"?` : "."} It ${then}. Types: ${names.join(", ")}`);
276
+ };
277
+
278
+ const warned = new Set<string>();
279
+ const badGradient = (fn: string, value: unknown, all: unknown, n: number) => {
280
+ const why = !Array.isArray(all) ? `expected { stops: [{ color, pos }] }, got ${value === null ? "null" : typeof value === "object" ? "an object without a stops array" : typeof value}`
281
+ : n < all.length ? `skipped ${all.length - n} of ${all.length} stops without a CSS color and a numeric pos` : "the gradient has no stops";
282
+ if (!warned.has(fn + why)) { warned.add(fn + why); console.warn(`[tweaks] ${fn}(): ${why}`); }
283
+ };
284
+ // The words for the core's notices (shared.ts notice()): what went wrong and the way out, downloaded only when one fires.
285
+ const NOTICES: Record<string, (...a: any[]) => string> = {
286
+ clamp: (key, v, min, max, type) => `set("${key}", ${JSON.stringify(v)}) clamped to ${min > -Infinity ? min : "−∞"}–${max < Infinity ? max : "∞"}; widen min/max${type === "slider" || type === "number" ? ", or set soft: true to allow it" : ""}`,
287
+ sharedKey: (k) => `persist: another live panel already saves under "${k}", so each overwrites the other's values; give one its own persist key`,
288
+ pointValue: (key, comps) => `"${key}": a point value that isn't a number starts at 0 (${comps})`,
289
+ swap: (label, min, max) =>`"${label}": min ${min} is above max ${max}, so they were swapped`,
290
+ theme: (key) => `theme: "${key}" isn't a --tw-* token ("--tw-accent", say) — skipped`,
291
+ ambiguousPath: (key, n) => `set("${key}") is ambiguous — ${n} controls read as that path (a key holding "." beside a folder); rename one`,
292
+ noPath: (key) => `set("${key}") — no control at that path`,
293
+ noValue: (key, type) => `set("${key}") ignored — a ${type} holds no value to set`,
294
+ ambiguousKey: (key, n, eg) => `set("${key}") is ambiguous — ${n} controls share that key; use a dotted path (e.g. "${eg}")`,
295
+ springDur: (key, v, shown) => `"${key}": a spring visualDuration of ${v} s is past the editor's 0.01–10 s, so it uses ${shown} s`,
296
+ springKept: (key, k, d, m) => `"${key}": a spring of stiffness ${k}, damping ${d}, mass ${m} is past what the editor draws (0.01–10 s, bounce ±95%); the value keeps it as written, the editor shows the nearest`,
297
+ noControl: (key, group) => (group ? `set("${key}") ignored — it's a folder/tabs group; set its children instead` : `set("${key}") ignored — no control has that key`),
298
+ };
299
+ const notice = (kind: string, args: unknown[]) => { const say = NOTICES[kind]; if (say) console.warn(`[tweaks] ${say(...args)}`); };
300
+ sayNow(notice);
301
+
302
+ export { escapeReverts, dragPane, badGradient, notice, decimalStep, unknownType, numField, dragGesture, svgEl, cssVar, accentColor, selectAllOnFocus, grabSurface, boxFrac, fitCanvas, triggerRow, springCurve, springSpan, springRun, springFromTime, timeFromSpring, arrowStep, sayWhenKeysStop, SPRING_DUR };
@@ -0,0 +1,19 @@
1
+ /* Inline SVG icons for the kit's core chrome (toolbar, folder chevron, select, hint marker),
2
+ * kept in one module so the license notice below covers them all. */
3
+ // ── Icons for the toolbar ──
4
+ // Adapted from Hugeicons' free set (MIT; per-icon origins in ../../THIRD-PARTY-NOTICES.md), path
5
+ // data compacted. The icon color is translucent, so strokes that cross double-composite into a
6
+ // bright hotspot: each icon is one path. Copy and presets keep separate parts so the hover can
7
+ // move them (tweaks.css), drawn to abut rather than overlap.
8
+ import { icon } from "./shared.js";
9
+ const ICON_COPY = icon('<path class="tw-copy-back" d="m5.5 16.5-.7-.2q-1.5-.6-2.1-2.1-.2-.7-.2-2.7v-2c0-3.3 0-5 1-6s2.7-1 6-1h2c1.4 0 2 0 2.7.2q1.6.5 2.1 2.1l.2.7"/><path class="tw-copy-front" d="M7.5 14.5c0-3.3 0-5 1-6s2.7-1 6-1 5 0 6 1 1 2.7 1 6 0 5-1 6-2.7 1-6 1-5 0-6-1-1-2.7-1-6"/>', "tw-toolbar-btn__copy");
10
+ const ICON_CHECK = icon('<path d="m5 14 3.5 3.5L19 6.5"/>', "tw-toolbar-btn__check", 2.4);
11
+ // Reset is Hugeicons' undo, a near-full circle, so the spin on click reads as a turn about the center.
12
+ const ICON_RESET = icon('<path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-7.6 4.2M3.3 3l.2 2q0 2.2.6 2.7t2.7.2l2-.2"/>');
13
+ const chevronIcon = (cls: string) => icon('<path d="M18 9s-4.4 6-6 6-6-6-6-6"/>', cls, 2.5);
14
+ const ICON_CHEVRON = chevronIcon("tw-chevron");
15
+ const ICON_PRESETS = icon('<path d="M20.2 13.5q1.8.8 1.8 1.5 0 .8-2.7 2l-5 2.2Q13 20 12 20q-.7 0-2.4-.8l-4.9-2.3Q2 16 2 15t1.8-1.5"/><path class="tw-layer-top" d="M9.6 4.8Q11.3 4 12 4t2.4.8l4.9 2.3Q22 8 22 9c0 .7-.9 1.1-2.7 2l-5 2.2Q13 14 12 14q-.7 0-2.4-.8l-4.9-2.3Q2 10 2 9c0-.7.9-1.1 2.7-2z"/>');
16
+ const ICON_X = icon('<path d="M18 6 6 18m12 0L6 6"/>', "", 2.2);
17
+ const ICON_INFO = icon('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4m-.25-3.75a.25.25 0 0 0 .5 0 .25.25 0 0 0-.5 0"/>');
18
+
19
+ export { ICON_COPY, ICON_CHECK, ICON_RESET, ICON_CHEVRON, ICON_PRESETS, ICON_X, ICON_INFO, chevronIcon };
@@ -0,0 +1,101 @@
1
+ // ── Persisted images. An upload's data URL can outgrow localStorage (~5 MB an origin), so a panel with `persist`
2
+ // keeps each one in IndexedDB and saves a short ref in its place: `tw-image:<hash>`. Records are keyed
3
+ // [storage key, hash], so one panel's images read as a range. Loaded only by a panel that persists
4
+ // an image control. Every call settles, never rejects: IndexedDB off or refused reads as null. ──
5
+
6
+ const REF = "tw-image:";
7
+ type Store = IDBObjectStore;
8
+ let db: Promise<IDBDatabase | null> | null = null;
9
+ // An open that never settles (another tab's pending delete or upgrade) reads as unavailable after 3s rather than holding
10
+ // panel.ready forever. The connection closes for another tab's upgrade or delete, and one the browser drops is reopened.
11
+ const open = () => db ||= new Promise<IDBDatabase | null>((done) => {
12
+ let late = false;
13
+ const t = setTimeout(() => { late = true; db = null; done(null); }, 3000);
14
+ try {
15
+ const r = indexedDB.open("tweakit", 1);
16
+ r.onupgradeneeded = () => r.result.createObjectStore("images");
17
+ r.onsuccess = () => {
18
+ const d = r.result; clearTimeout(t);
19
+ if (late) { d.close(); return; }
20
+ d.onversionchange = () => { d.close(); db = null; }; d.onclose = () => { db = null; };
21
+ done(d);
22
+ };
23
+ r.onerror = () => { clearTimeout(t); db = null; done(null); };
24
+ } catch { clearTimeout(t); done(null); } // no indexedDB at all
25
+ });
26
+ // One transaction; resolves with what `fn` returns once it commits, null if it fails.
27
+ const run = <T>(mode: IDBTransactionMode, fn: (s: Store) => T) => open().then((d) => d && new Promise<T | null>((done) => {
28
+ try { const t = d.transaction("images", mode), out = fn(t.objectStore("images")); t.oncomplete = () => done(out); t.onerror = t.onabort = () => done(null); }
29
+ catch { done(null); }
30
+ }));
31
+ const range = (key: string) => IDBKeyRange.bound([key], [key, []]); // an array sorts after every string, so [key, []] caps the range
32
+
33
+ // cyrb53: a 53-bit string hash, plenty to tell one panel's few images apart.
34
+ const hash = (s: string) => {
35
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
36
+ for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); h1 = Math.imul(h1 ^ c, 2654435761); h2 = Math.imul(h2 ^ c, 1597334677); }
37
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
38
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
39
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36) + s.length.toString(36);
40
+ };
41
+
42
+ const refFor = (dataUrl: string) => REF + hash(dataUrl);
43
+
44
+ /** Every image stored under `key`, ref → data URL; null when IndexedDB can't be used. */
45
+ const load = (key: string) => run("readonly", (s) => {
46
+ const out = new Map<string, string>();
47
+ s.openCursor(range(key)).onsuccess = function () { const c = this.result; if (!c) return; if (typeof c.value === "string") out.set(REF + (c.key as string[])[1], c.value); c.continue(); };
48
+ return out;
49
+ });
50
+ /** One image by its ref (a preset another tab saved after this panel loaded); null when it isn't there. */
51
+ const get = (key: string, ref: string) => run("readonly", (s) => { const r = s.get([key, ref.slice(REF.length)]); return r; }).then((r) => (r && typeof r.result === "string" ? r.result : null));
52
+ const put = (key: string, ref: string, dataUrl: string) => run("readwrite", (s) => { s.put(dataUrl, [key, ref.slice(REF.length)]); return true; });
53
+ /** Delete the images under `key` that `refs` name. */
54
+ const prune = (key: string, refs: string[]) => run("readwrite", (s) => { for (const r of refs) s.delete([key, r.slice(REF.length)]); return true; });
55
+
56
+ /** Each ref a stored snapshot or preset names, read from its JSON whatever the schema: a renamed field's preset still counts. */
57
+ const refsIn = (v: unknown) => new Set<string>(JSON.stringify(v ?? null).match(/tw-image:[0-9a-z]+/g) || []);
58
+ /** Delete the records under `key` this panel knows (`known`) that nothing in `named` names any more. */
59
+ const pruneUnnamed = (key: string, known: Map<string, string>, named: unknown[]) => {
60
+ const keep = refsIn(named), gone = [...known.keys()].filter((r) => !keep.has(r));
61
+ for (const r of gone) known.delete(r);
62
+ if (gone.length) prune(key, gone);
63
+ };
64
+ /** `presets` with `ref` swapped for `with` everywhere it appears, or null when nothing names it. */
65
+ const swapRef = (presets: unknown, ref: string, withText: string) => {
66
+ const text = JSON.stringify(presets);
67
+ return text.includes(ref) ? JSON.parse(text.split(ref).join(JSON.stringify(withText).slice(1, -1))) : null;
68
+ };
69
+
70
+ /** The store bound to one panel: `known` holds the records it has loaded or written (ref → data URL), `refused` runs when
71
+ * a write fails with its ref (the panel saves again, that image inline), and the presets pair reads and writes its presets. */
72
+ const bind = (key: string, known: Map<string, string>, refused: (ref: string) => void, readPresets: () => unknown, writePresets: (v: unknown) => boolean) => {
73
+ const failed = new Set<string>(), hashed = new WeakMap<object, [string, string]>();
74
+ // Each row's last data URL and its ref, so a save that didn't touch the image doesn't hash megabytes again.
75
+ const refOf = (row: object, v: string) => { const c = hashed.get(row); if (c?.[0] === v) return c[1]; const r = refFor(v); hashed.set(row, [v, r]); return r; };
76
+ // A record the store refused: a preset that names it takes the data URL inline, or an empty image if storage can't fit it.
77
+ const inlinePresets = (ref: string, v: string) => {
78
+ const inlined = swapRef(readPresets(), ref, v);
79
+ if (!inlined || writePresets(inlined)) return;
80
+ writePresets(swapRef(readPresets(), ref, ""));
81
+ console.warn("[tweaks] persist: an image a preset holds didn't fit in storage — the preset keeps the rest");
82
+ };
83
+ return {
84
+ /** The ref `row`'s image saves as, or null to save it inline (the store refused it). Written once, and again whenever
85
+ * `stored` (what storage holds now) no longer names it: a tab that replaced it may have pruned the record. */
86
+ save(row: object, v: string, stored: unknown) {
87
+ const ref = refOf(row, v);
88
+ if (failed.has(ref)) return null;
89
+ if (!known.has(ref) || !refsIn(stored).has(ref)) {
90
+ known.set(ref, v);
91
+ put(key, ref, v).then((ok) => { if (ok) return; known.delete(ref); failed.add(ref); refused(ref); inlinePresets(ref, v); });
92
+ }
93
+ return ref;
94
+ },
95
+ prune: (named: unknown[]) => pruneUnnamed(key, known, named),
96
+ /** A ref this panel hasn't seen (a preset another tab saved), read from the store. */
97
+ fetch: (ref: string) => get(key, ref).then((url) => { if (url) known.set(ref, url); return url; }),
98
+ };
99
+ };
100
+
101
+ export { refFor, load, bind };
@@ -0,0 +1,46 @@
1
+ /* Lazy-control loading — the dynamic-import map behind the code-split build, plus the schema
2
+ * scan that preloads what a panel needs before it assembles. */
3
+ import { getControl } from "./shared.js";
4
+ import type { Meta } from "./schema.js";
5
+
6
+ /** What the preload scan reads off a meta. */
7
+ type MetaNode = Pick<Meta, "type" | "children" | "pages">;
8
+
9
+ // ── Lazy controls — each import registers its constructor(s) into the shared registry ──
10
+ // TW_SPLIT is an esbuild `define`: `false` for the single-file bundle, where single.ts imports
11
+ // every control statically, so this map is empty and esbuild drops the import()s.
12
+ declare const TW_SPLIT: boolean;
13
+ const LAZY_IMPORT: Record<string, () => Promise<unknown>> = TW_SPLIT ? {
14
+ number: () => import("./heavy.js"), // heavy.js registers the Number control itself
15
+ interval: () => import("./controls/interval.js"),
16
+ color: () => import("./controls/color.js"),
17
+ gradient: () => import("./controls/gradient.js"),
18
+ shadow: () => import("./controls/shadow.js"),
19
+ sides: () => import("./controls/sides.js"),
20
+ tabs: () => import("./controls/tabs.js"),
21
+ image: () => import("./controls/image.js"),
22
+ fpsgraph: () => import("./controls/monitor.js"),
23
+ monitor: () => import("./controls/monitor.js"),
24
+ spring: () => import("./controls/spring.js"),
25
+ cubicbezier: () => import("./controls/bezier.js"),
26
+ // motion registers last, so a failed editor chunk leaves it unregistered for the next panel to retry.
27
+ motion: () => Promise.all([import("./controls/bezier.js"), import("./controls/spring.js")]).then(() => import("./controls/motion.js")),
28
+ point: () => import("./controls/point.js"),
29
+ plot: () => import("./controls/plot.js"),
30
+ } : {};
31
+ const loading: Record<string, Promise<unknown>> = {};
32
+ const ensure = (type: string) => (getControl(type) || !LAZY_IMPORT[type]) ? null : (loading[type] ||= LAZY_IMPORT[type]().catch((e) => { delete loading[type]; console.error(`[tweaks] control chunk "${type}" failed to load:`, e); throw e; })); // a rejection isn't cached, so a later panel retries
33
+ const scanTypes = (metas: MetaNode[], set = new Set<string>()) => {
34
+ for (const m of metas) {
35
+ set.add(m.type);
36
+ if (m.children) scanTypes(m.children, set);
37
+ if (m.pages) for (const pg of m.pages) scanTypes(pg.children, set);
38
+ }
39
+ return set;
40
+ };
41
+ const ensureForMetas = (metas: MetaNode[]) => {
42
+ const pend = [...scanTypes(metas)].map(ensure).filter(Boolean);
43
+ return pend.length ? Promise.all(pend) : null;
44
+ };
45
+
46
+ export { ensureForMetas, scanTypes, LAZY_IMPORT }; // LAZY_IMPORT is exported for the registry sync test only