tweakit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -1,113 +1,237 @@
1
1
  /* Tweaks — what only the lazy controls use: the numeric field engine and its scrub, the
2
2
  * press-drag gesture, the SVG factory, canvas sizing and the modal-trigger row, kept out of
3
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";
4
+ import {
5
+ el,
6
+ btn,
7
+ txt,
8
+ clamp,
9
+ carrySkin,
10
+ portalTo,
11
+ activeIn,
12
+ words,
13
+ quietFocus,
14
+ stepPrecision,
15
+ gridEnds,
16
+ roundToStep,
17
+ icon,
18
+ registerControl,
19
+ announce,
20
+ sayNow,
21
+ } from "./shared.js";
5
22
  import type { Built, NumSpec, NumField, OnChange } from "./shared.js";
6
23
  import type { Meta } from "./schema.js";
7
24
 
8
25
  const svgNS = "http://www.w3.org/2000/svg";
9
26
  // 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; };
27
+ const svgEl = <K extends keyof SVGElementTagNameMap>(tag: K, cls?: string): Built<SVGElementTagNameMap[K]> => {
28
+ const n = document.createElementNS(svgNS, tag);
29
+ if (cls) n.setAttribute("class", cls);
30
+ return n;
31
+ };
11
32
  // A token reads back as its light-dark(light, dark) pair, which a canvas can't parse: keep the arm the node's
12
33
  // color-scheme paints with ("light dark" follows the OS), innermost pairs included, as CSS would.
13
34
  const pickScheme = (v: string, cs: CSSStyleDeclaration) => {
14
35
  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);
36
+ const s = cs.colorScheme || "",
37
+ dark = /\bdark\b/.test(s) && (!/\blight\b/.test(s) || matchMedia("(prefers-color-scheme: dark)").matches);
16
38
  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; }
39
+ let depth = 0,
40
+ comma = -1,
41
+ j = i + 11;
42
+ for (; j < v.length; j++) {
43
+ const c = v[j];
44
+ if (c === "(") depth++;
45
+ else if (c === ")") {
46
+ if (!depth) break;
47
+ depth--;
48
+ } else if (c === "," && !depth && comma < 0) comma = j;
49
+ }
19
50
  if (comma < 0 || j >= v.length) return ""; // malformed: the caller's fallback color
20
51
  v = v.slice(0, i) + (dark ? v.slice(comma + 1, j) : v.slice(i + 11, comma)).trim() + v.slice(j + 1);
21
52
  }
22
53
  return v;
23
54
  };
24
- const cssVar = (node: Element, name: string) => { const r = getComputedStyle(node); return pickScheme(r.getPropertyValue(name).trim(), r); };
55
+ const cssVar = (node: Element, name: string) => {
56
+ const r = getComputedStyle(node);
57
+ return pickScheme(r.getPropertyValue(name).trim(), r);
58
+ };
25
59
  // 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"; };
60
+ const accentColor = (node: Element) => {
61
+ const r = getComputedStyle(node);
62
+ const v = (n: string) => pickScheme(r.getPropertyValue(n).trim(), r);
63
+ return v("--twk-accent") || v("--twk-text-primary") || "#fff";
64
+ };
27
65
  // Select the whole value on focus (the hex field: you type a new color, never edit a digit).
28
66
  // Browsers collapse a focus-time selection on the click's own mouseup, so the first primary
29
67
  // mouseup after a pointer-initiated focus is swallowed while the whole value is still selected.
30
68
  const selectAllOnFocus = (input: HTMLInputElement) => {
31
69
  let swallowUp = false;
32
- input.addEventListener("pointerdown", (e) => { swallowUp = e.button === 0 && document.activeElement !== input; });
70
+ input.addEventListener("pointerdown", (e) => {
71
+ swallowUp = e.button === 0 && activeIn(input) !== input;
72
+ });
33
73
  input.addEventListener("focus", () => input.select());
34
74
  input.addEventListener("mouseup", (e) => {
35
75
  if (!swallowUp) return;
36
76
  swallowUp = false;
37
- if (e.button === 0 && document.activeElement === input && input.selectionStart === 0 && input.selectionEnd === input.value.length) e.preventDefault();
77
+ if (
78
+ e.button === 0 &&
79
+ activeIn(input) === input &&
80
+ input.selectionStart === 0 &&
81
+ input.selectionEnd === input.value.length
82
+ )
83
+ e.preventDefault();
38
84
  });
39
85
  };
40
86
  // Escape in an edited field restores `kept` and stops; otherwise it passes on and closes the pane. Without this,
41
87
  // 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
- });
88
+ const escapeReverts = (input: HTMLInputElement, kept: () => string) =>
89
+ input.addEventListener("keydown", (e: KeyboardEvent) => {
90
+ if (e.key !== "Escape" || input.value === kept()) return;
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ input.value = kept();
94
+ });
95
+ // Drag a pane by its edge (padding, or a gap between parts) to uncover what's beneath; it keeps that offset from its
96
+ // row while open (st.moved), and the next open resets it. Bound by popover() on a movable pane's first open.
97
+ const dragPane = (pop: HTMLElement, st: { open: boolean; moved: boolean }, place: () => void) =>
98
+ pop.addEventListener("pointerdown", (e: PointerEvent) => {
99
+ if (e.target !== pop || e.button !== 0 || !st.open) return;
100
+ e.preventDefault(); // no text selection, and focus stays where it was
101
+ const id = e.pointerId,
102
+ sx = e.clientX,
103
+ sy = e.clientY,
104
+ ox = parseFloat(pop.style.left),
105
+ oy = parseFloat(pop.style.top),
106
+ wasMoved = st.moved;
107
+ let going = false;
108
+ try {
109
+ pop.setPointerCapture(id);
110
+ } catch {}
111
+ const move = (m: PointerEvent) => {
112
+ if (m.pointerId !== id) return;
113
+ if (!going) {
114
+ if (Math.hypot(m.clientX - sx, m.clientY - sy) < 4) return;
115
+ going = st.moved = true;
116
+ pop.classList.add("is-moving");
117
+ }
118
+ pop.style.left = ox + m.clientX - sx + "px";
119
+ pop.style.top = oy + m.clientY - sy + "px";
120
+ place();
121
+ };
122
+ const end = (m: PointerEvent) => {
123
+ if (m.pointerId === id) stop();
124
+ };
125
+ // Escape mid-move puts it back and keeps it open. Capture phase, so it beats the pane's own Escape.
126
+ const key = (k: KeyboardEvent) => {
127
+ if (k.key !== "Escape" || !going) return;
128
+ k.preventDefault();
129
+ k.stopPropagation();
130
+ pop.style.left = ox + "px";
131
+ pop.style.top = oy + "px";
132
+ st.moved = wasMoved;
133
+ place();
134
+ stop();
135
+ try {
136
+ pop.releasePointerCapture(id);
137
+ } catch {}
138
+ };
139
+ const stop = () => {
140
+ pop.removeEventListener("pointermove", move);
141
+ pop.removeEventListener("pointerup", end);
142
+ pop.removeEventListener("pointercancel", end);
143
+ pop.removeEventListener("lostpointercapture", end);
144
+ document.removeEventListener("keydown", key, true);
145
+ pop.classList.remove("is-moving");
146
+ };
147
+ // lost: the node left mid-press
148
+ pop.addEventListener("pointermove", move);
149
+ pop.addEventListener("pointerup", end);
150
+ pop.addEventListener("pointercancel", end);
151
+ pop.addEventListener("lostpointercapture", end);
152
+ document.addEventListener("keydown", key, true);
153
+ });
74
154
  // Press-drag on a node: onDown on pointerdown (pointer captured), onMove per move, onEnd on
75
155
  // 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 } = {}) {
156
+ function dragGesture(
157
+ node: HTMLElement,
158
+ {
159
+ onDown,
160
+ onMove,
161
+ onEnd,
162
+ }: { onDown?: (e: PointerEvent) => void; onMove?: (e: PointerEvent) => void; onEnd?: (e: PointerEvent) => void } = {},
163
+ ) {
77
164
  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);
165
+ const end = (e: PointerEvent) => {
166
+ if (activeId === null || e.pointerId !== activeId) return;
167
+ activeId = null;
168
+ onEnd && onEnd(e);
169
+ };
170
+ node.addEventListener("pointerdown", (e) => {
171
+ if (e.button !== 0 || activeId !== null) return;
172
+ activeId = e.pointerId;
173
+ try {
174
+ node.setPointerCapture(e.pointerId);
175
+ } catch {}
176
+ onDown && onDown(e);
177
+ });
178
+ node.addEventListener("pointermove", (e) => {
179
+ if (e.pointerId !== activeId) return;
180
+ if (e.buttons === 0) return end(e);
181
+ onMove && onMove(e);
182
+ });
183
+ node.addEventListener("pointerup", end);
184
+ node.addEventListener("pointercancel", end);
82
185
  node.addEventListener("lostpointercapture", end); // capture lost mid-drag (popover unmounting) ends it too
83
186
  }
84
187
  // 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
- });
188
+ const grabSurface = (surface: HTMLElement, set: (e: PointerEvent) => void) =>
189
+ dragGesture(surface, {
190
+ onDown: (e) => {
191
+ surface.classList.add("is-grabbing");
192
+ set(e);
193
+ },
194
+ onMove: set,
195
+ onEnd: () => surface.classList.remove("is-grabbing"),
196
+ });
90
197
  // 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)]; };
198
+ const boxFrac = (e: MouseEvent, box: Element): [number, number] => {
199
+ const r = box.getBoundingClientRect();
200
+ return [clamp((e.clientX - r.left) / r.width, 0, 1), clamp((e.clientY - r.top) / r.height, 0, 1)];
201
+ };
92
202
  // 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];
203
+ const arrowStep = (e: KeyboardEvent): [number, number] => [
204
+ e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0,
205
+ e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0,
206
+ ];
94
207
  // A 2D surface's readout rides on aria-description, which a screen reader speaks on focus and never again: the returned
95
208
  // 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); }; };
209
+ const sayWhenKeysStop = (node: HTMLElement) => {
210
+ let t = 0;
211
+ return () => {
212
+ clearTimeout(t);
213
+ t = setTimeout(() => {
214
+ if (node.isConnected) announce(node.getAttribute("aria-description") || "");
215
+ }, 250);
216
+ };
217
+ };
97
218
  // Size a canvas to its CSS box × devicePixelRatio (capped) and scale the context to CSS pixels; returns [cssW, cssH].
98
219
  const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, maxDpr = Infinity): [number, number] => {
99
220
  const r = canvas.getBoundingClientRect();
100
221
  const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
101
- canvas.width = r.width * dpr; canvas.height = r.height * dpr;
222
+ canvas.width = r.width * dpr;
223
+ canvas.height = r.height * dpr;
102
224
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
103
225
  return [r.width, r.height];
104
226
  };
105
- // The modal-trigger row (color, gradient, point): a row button, label left, a preview cluster (`right`) the caller fills.
227
+ // The modal-trigger row (color, gradient, point): a row button, label left, a preview cluster (`right`) the caller
228
+ // fills.
106
229
  const triggerRow = (cls: string, label: string) => {
107
230
  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);
231
+ const trigger = btn("twk-trigger");
232
+ trigger.setAttribute("aria-expanded", "false");
233
+ const right = el("span", "twk-trigger-right");
234
+ trigger.append(txt("span", "twk-trigger-label", label), right);
111
235
  root.append(trigger);
112
236
  return { root, trigger, right };
113
237
  };
@@ -117,37 +241,82 @@ const ICON_GRIP = icon('<path d="M6 4v8M10 4v8"/>', "", 2, 16); // 1.5px bars at
117
241
 
118
242
  // Grab guide — a dotted line from the grab point to the cursor plus a value bubble, portaled
119
243
  // 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> }
244
+ interface GuideEl extends HTMLDivElement {
245
+ readonly children: HTMLCollectionOf<HTMLSpanElement>;
246
+ }
121
247
  function makeGrabGuide() {
122
- let g: GuideEl | null = null, y = 0, x0 = 0, bx = 0;
248
+ let g: GuideEl | null = null,
249
+ y = 0,
250
+ x0 = 0,
251
+ bx = 0;
123
252
  return {
124
253
  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
254
+ g = el("div", "twk-grab-guide twk-portal") as GuideEl;
255
+ g.innerHTML = `<span class="twk-grab-line"></span><span class="twk-grab-dot"></span><span class="twk-grab-arrow"></span><span class="twk-grab-bubble"></span>`;
256
+ y = atY;
257
+ x0 = x;
258
+ bx = bubbleX ?? x; // bubble anchors over the field center, not the cursor
128
259
  g.children[1].style.cssText = `left:${x}px;top:${y}px`;
129
260
  carrySkin(g, anchor); // the anchor panel's theme + scheme, like every portal
130
- document.body.appendChild(g);
261
+ portalTo(g, anchor);
131
262
  },
132
263
  move(x: number, text: string) {
133
264
  if (!g) return;
134
265
  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;
266
+ // arrowhead at the cursor, pointing in the drag direction
267
+ g.children[2].style.cssText = `left:${x}px;top:${y}px;transform:translate(-50%,-50%) scaleX(${x >= x0 ? 1 : -1})`;
268
+ g.children[3].style.cssText = `left:${bx}px;top:${y - 16}px`;
269
+ g.children[3].textContent = text;
270
+ },
271
+ hide() {
272
+ if (g) {
273
+ g.remove();
274
+ g = null;
275
+ }
137
276
  },
138
- hide() { if (g) { g.remove(); g = null; } },
139
277
  };
140
278
  }
141
279
 
142
280
  // Drag-to-scrub on a grab handle: 1px ≈ one step (Shift ×10, Alt ×0.1), re-anchoring on a
143
281
  // 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();
282
+ function attachScrub(
283
+ grab: HTMLElement,
284
+ wrap: HTMLElement,
285
+ step: number,
286
+ read: () => number,
287
+ apply: (v: number) => void,
288
+ text: () => string,
289
+ ) {
290
+ let downX = 0,
291
+ downV = 0,
292
+ curK = 1;
293
+ const gd = makeGrabGuide();
146
294
  // Every end path matters: capture lost mid-scrub must still hide the full-screen guide.
147
295
  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(); },
296
+ onDown: (e) => {
297
+ e.preventDefault();
298
+ downX = e.clientX;
299
+ downV = read();
300
+ curK = 1;
301
+ grab.classList.add("is-dragging");
302
+ const br = wrap.getBoundingClientRect();
303
+ gd.show(e.clientX, br.top + br.height / 2, br.left + br.width / 2, wrap);
304
+ gd.move(e.clientX, text());
305
+ },
306
+ onMove: (e) => {
307
+ const k = e.shiftKey ? 10 : e.altKey ? 0.1 : 1;
308
+ if (k !== curK) {
309
+ curK = k;
310
+ downX = e.clientX;
311
+ downV = read();
312
+ }
313
+ apply(downV + (e.clientX - downX) * step * k);
314
+ gd.move(e.clientX, text());
315
+ },
316
+ onEnd: () => {
317
+ grab.classList.remove("is-dragging");
318
+ gd.hide();
319
+ },
151
320
  });
152
321
  }
153
322
 
@@ -158,71 +327,133 @@ function numField(spec: NumSpec, onChange?: (v: number) => void): NumField {
158
327
  // A 0/negative/non-finite step breaks round-to-step (reachable via a point component's step).
159
328
  const step = Number.isFinite(+spec.step) && +spec.step > 0 ? +spec.step : 1;
160
329
  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); };
330
+ let min = bound(spec.min),
331
+ max = bound(spec.max);
332
+ // an inverted pair would clamp every value to one end
333
+ if (Number.isFinite(min) && Number.isFinite(max) && max < min) {
334
+ notice("swap", [spec.label ?? "number", min, max]);
335
+ const t = min;
336
+ min = max;
337
+ max = t;
338
+ }
339
+ const anchor = Number.isFinite(min) ? min : 0,
340
+ decimals = stepPrecision(step); // min-anchored, like the slider
341
+ // on-grid ends, so fit(fit(x)) === fit(x)
342
+ const [lo, hi] = gridEnds(
343
+ Number.isFinite(min) ? min : -Infinity,
344
+ Number.isFinite(max) ? max : Infinity,
345
+ step,
346
+ anchor,
347
+ );
348
+ const fit = (val: number) => {
349
+ const n = roundToStep(val, anchor, step);
350
+ return spec.soft ? n : clamp(n, lo, hi);
351
+ };
166
352
  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);
353
+ const root = el("div", spec.row ? "twk-row" : "twk-field");
354
+ const wrap = el("div", "twk-num-wrap");
355
+ const grab = el("span", "twk-num-grab", ICON_GRIP);
356
+ grab.setAttribute("aria-hidden", "true");
357
+ grab.title = words()("dragToAdjust", "Drag to adjust");
358
+ const inp = el("input", "twk-num");
359
+ inp.type = "text";
360
+ inp.inputMode = "decimal";
361
+ inp.setAttribute("aria-label", spec.unit ? `${spec.label} (${spec.unit})` : spec.label);
362
+ inp.value = value.toFixed(decimals);
171
363
  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); };
364
+ wrap.append(grab, inp);
365
+ if (spec.unit) wrap.append(txt("span", "twk-num-unit", spec.unit));
366
+ root.append(txt("span", spec.row ? "twk-row-label" : "twk-field-label", spec.label), wrap);
367
+ const set = (val: number | string, fire = true) => {
368
+ val = +val;
369
+ if (!Number.isFinite(val)) return;
370
+ value = fit(val);
371
+ inp.value = value.toFixed(decimals);
372
+ if (fire && onChange) onChange(value);
373
+ };
174
374
  // Typed text is read strictly as a whole number, as the slider's editor reads it (not parseFloat, which takes "1,000"
175
375
  // as 1 and "12abc" as 12); the field's unit may be typed along. NaN for anything else.
176
376
  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); });
377
+ // Only a readable number that lands on a new value commits; otherwise the field shows the kept value again and fires
378
+ // nothing.
379
+ inp.addEventListener("change", () => {
380
+ const n = typed();
381
+ if (Number.isFinite(n) && fit(n) !== value) set(n);
382
+ else inp.value = value.toFixed(decimals);
383
+ });
179
384
  escapeReverts(inp, () => value.toFixed(decimals));
180
385
  // ↑/↓ step off the half-typed value (a typed "4" then ↑ reads 5), the keyboard twin of the scrub.
181
386
  inp.addEventListener("keydown", (e) => {
182
387
  if (e.key === "Enter") return inp.blur();
183
- const d = e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0; if (!d) return;
388
+ const d = e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0;
389
+ if (!d) return;
184
390
  e.preventDefault();
185
391
  const n = typed();
186
392
  set((Number.isFinite(n) ? n : value) + d * step * (e.shiftKey ? 10 : 1));
187
393
  });
188
- attachScrub(grab, wrap, step, () => value, set, () => inp.value);
394
+ attachScrub(
395
+ grab,
396
+ wrap,
397
+ step,
398
+ () => value,
399
+ set,
400
+ () => inp.value,
401
+ );
189
402
  return { el: root, set: (val: number | string) => set(val, false), get: () => value, range: [lo, hi] };
190
403
  }
191
404
 
192
405
  // ── 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.
406
+ // The coarsest power of ten a number sits on (0.5 → 0.1, 3.14159 → 0.00001, 1230 → 10), read at 12 significant digits
407
+ // so
408
+ // 0.1 + 0.2 counts as 0.3; Infinity for 0 or a non-number, which constrains nothing. A step no coarser keeps it
409
+ // exactly.
410
+ // Past eight significant digits a number was computed, not written (1/3, Math.PI): it steps at its fourth digit, as a
411
+ // slider rounds it, so an arrow moves it by 0.0001, not by 1e-12.
197
412
  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++;
413
+ const n = Number(Math.abs(+(v as number)).toPrecision(12));
414
+ if (!n || !Number.isFinite(n)) return Infinity;
415
+ const d = stepPrecision(n),
416
+ e = Math.floor(Math.log10(n));
417
+ if (d) return Number(`1e${e + d + 1 > 8 ? e - 3 : -d}`);
418
+ let k = 0;
419
+ while (k < 15 && n % 10 ** (k + 1) === 0) k++;
201
420
  return 10 ** k;
202
421
  };
203
422
  // 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
423
  // 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));
424
+ registerControl("number", (meta: Meta, onChange?: OnChange) =>
425
+ numField(
426
+ { ...meta, step: meta.step ?? Math.min(1, decimalStep(meta.value), decimalStep(meta.min)), row: true },
427
+ onChange,
428
+ ),
429
+ );
206
430
 
207
431
  // ── Spring step response — shared by the spring and motion controls. The window runs to the
208
432
  // 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.
433
+ // Motion's visualDuration + bounce, resolved as motion-dom's spring generator does (mass 1): a bounce maps to a damping
434
+ // ratio as SwiftUI's does, floored at 0.05 either way; the period is visualDuration × 1.2; and an overdamped (negative)
435
+ // bounce scales the frequency by a Newton solve so it arrives when the critically damped spring would. Copied value for
436
+ // value, so what's tuned here plays the same in Motion.
213
437
  const CRIT = (2 * Math.PI) / 1.2;
214
438
  // The visualDuration (s) the spring editor shows and resolves: down to 10 ms, up to the 10 s a spring's linear() runs.
215
439
  const SPRING_DUR = [0.01, 10] as const;
216
440
  const bounceRatio = (b: number) => (b < 0 ? 1 / Math.max(1 + b, 0.05) : Math.max(1 - b, 0.05));
217
441
  const overdampedScale = (z: number) => {
218
442
  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);
443
+ const r = Math.sqrt(z * z - 1),
444
+ slow = z - r,
445
+ fast = z + r,
446
+ target = 2 * r * Math.exp(-CRIT) * (1 + CRIT);
220
447
  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));
448
+ for (let i = 1; i < 12; i++)
449
+ x -=
450
+ (fast * Math.exp(-slow * x) - slow * Math.exp(-fast * x) - target) / (Math.exp(-fast * x) - Math.exp(-slow * x));
222
451
  return x / CRIT;
223
452
  };
224
453
  const springFromTime = (visualDuration: number, bounce: number) => {
225
- const z = bounceRatio(bounce), root = (CRIT / visualDuration) * overdampedScale(z), k = root * root;
454
+ const z = bounceRatio(bounce),
455
+ root = (CRIT / visualDuration) * overdampedScale(z),
456
+ k = root * root;
226
457
  return { stiffness: k, damping: 2 * z * Math.sqrt(k), mass: 1 };
227
458
  };
228
459
  // Its inverse, for physics keys handed to a time-mode editor: the visualDuration + bounce Motion would resolve to them.
@@ -233,70 +464,163 @@ const timeFromSpring = (k: number, d: number, m = 1) => {
233
464
  // How fast the slowest mode dies out: ζω₀, or an overdamped spring's slow root (ζω₀ is its fast one, so the preview
234
465
  // window shrank to milliseconds and drew a near-flat line).
235
466
  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));
467
+ const springSpan = (k: number, d: number, m: number) =>
468
+ Math.min(2.2, 9 / Math.max(decayRate(Math.sqrt(k / m), d / (2 * Math.sqrt(k * m))), 0.5));
237
469
  // Closed-form step response of a damped harmonic oscillator (under/critical/over): position at time t.
238
470
  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)); }
471
+ const w0 = Math.sqrt(k / m),
472
+ z = d / (2 * Math.sqrt(k * m));
473
+ if (z < 1 - 1e-4) {
474
+ const wd = w0 * Math.sqrt(1 - z * z);
475
+ return (t: number) => 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t));
476
+ }
241
477
  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);
478
+ const s = Math.sqrt(z * z - 1),
479
+ a = w0 * (z + s),
480
+ b = w0 * (z - s);
243
481
  return (t: number) => 1 - (a * Math.exp(-b * t) - b * Math.exp(-a * t)) / (a - b);
244
482
  };
245
483
  // N samples over the span, for drawing.
246
484
  function springCurve(k: number, d: number, m: number, N = 64, T = springSpan(k, d, m)) {
247
- const x = springAt(k, d, m), out: number[] = [];
485
+ const x = springAt(k, d, m),
486
+ out: number[] = [];
248
487
  for (let i = 0; i < N; i++) out.push(x((i / (N - 1)) * T));
249
488
  return out;
250
489
  }
251
490
  // 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.
491
+ // (an overdamped spring's slow root, not ζω₀), capped at 10 s, where the last stop is 1 so it ends at rest. There are
492
+ // at least 24 stops a wobble plus one on every peak and trough, each at its own percentage, so the overshoots land
493
+ // where they are: stops spaced by duration alone give a bouncy spring three a wobble and miss its peaks by a third.
255
494
  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);
495
+ const w0 = Math.sqrt(k / m),
496
+ z = d / (2 * Math.sqrt(k * m)),
497
+ x = springAt(k, d, m);
257
498
  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);
499
+ const wd = z < 1 ? w0 * Math.sqrt(1 - z * z) : 0,
500
+ n = Math.min(Math.max(48, Math.ceil(wd ? (24 * span * wd) / (2 * Math.PI) : span * 40)), 800);
259
501
  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
502
+ // every extremum of the underdamped response
503
+ if (wd) for (let t = Math.PI / wd; t < span; t += Math.PI / wd) ts.push(t);
261
504
  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)}%`);
505
+ const stops = ts
506
+ .filter((t, i) => i === 0 || t - ts[i - 1] > 1e-6)
507
+ .map((t, i, a) => `${i === a.length - 1 ? 1 : +x(t).toFixed(3)} ${+((t / span) * 100).toFixed(2)}%`);
263
508
  return { duration: Math.round(span * 1000), easing: `linear(${stops.join(", ")})` };
264
509
  };
265
510
 
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.
511
+ // The name a typo is a single edit from (a letter added, dropped or changed), or that another library or an older
512
+ // tweakit calls it by. It and the notices below are lazy, as only a mistake needs them. unknownType: a schema or markup
513
+ // `type` no control answers to. Its list leaves out the color module's picker body, registered as a control but not a
514
+ // type to write.
515
+ const SYNONYMS: Record<string, string> = Object.assign(Object.create(null), {
516
+ bezier: "cubicbezier",
517
+ "cubic-bezier": "cubicbezier",
518
+ easing: "motion",
519
+ toggle: "checkbox",
520
+ switch: "checkbox",
521
+ bool: "checkbox",
522
+ boolean: "checkbox",
523
+ dropdown: "list",
524
+ select: "list",
525
+ string: "text",
526
+ textarea: "text",
527
+ range: "slider",
528
+ fps: "fpsgraph",
529
+ });
269
530
  const nearest = (s: string, names: string[]) => {
270
531
  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)); });
532
+ if (names.includes(SYNONYMS[a])) return SYNONYMS[a];
533
+ return names.find((n) => {
534
+ const k = n.toLowerCase();
535
+ let i = 0;
536
+ while (i < a.length && a[i] === k[i]) i++;
537
+ return [
538
+ [1, 1],
539
+ [0, 1],
540
+ [1, 0],
541
+ ].some(([x, y]) => a.slice(i + x) === k.slice(i + y));
542
+ });
272
543
  };
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(", ")}`);
544
+ const THEN: Record<string, string> = {
545
+ control: "is skipped, as an object with value, options or action is read as a control",
546
+ folder: "builds a folder",
547
+ markup: "is skipped",
548
+ };
549
+ const unknownType = (where: string, type: string, known: string[], then: "control" | "folder" | "markup") => {
550
+ const names = [...new Set(known)].filter((t) => t !== "folder" && t !== "colorpicker").sort(),
551
+ near = nearest(type, names);
552
+ console.warn(
553
+ `[tweaks] ${where}: "${type}" isn't a control type${near ? ` — did you mean "${near}"?` : "."} It ${THEN[then]}. Types: ${names.join(", ")}`,
554
+ );
276
555
  };
277
556
 
278
557
  const warned = new Set<string>();
279
558
  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}`); }
559
+ const why = !Array.isArray(all)
560
+ ? `expected { stops: [{ color, pos }] }, got ${value === null ? "null" : typeof value === "object" ? "an object without a stops array" : typeof value}`
561
+ : n < all.length
562
+ ? `skipped ${all.length - n} of ${all.length} stops without a CSS color and a numeric pos`
563
+ : "the gradient has no stops";
564
+ if (!warned.has(fn + why)) {
565
+ warned.add(fn + why);
566
+ console.warn(`[tweaks] ${fn}(): ${why}`);
567
+ }
283
568
  };
284
- // The words for the core's notices (shared.ts notice()): what went wrong and the way out, downloaded only when one fires.
569
+ // The words for the core's notices (shared.ts notice()): what went wrong and the way out, downloaded only when one
570
+ // fires.
285
571
  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`,
572
+ clamp: (key, v, min, max, type) =>
573
+ `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" : ""}`,
574
+ sharedKey: (k) =>
575
+ `persist: another live panel already saves under "${k}", so each overwrites the other's values; give one its own persist key`,
288
576
  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`,
577
+ swap: (label, min, max) => `"${label}": min ${min} is above max ${max}, so they were swapped`,
578
+ theme: (key) =>
579
+ `theme: "${key}" isn't a --twk-* token (${key.startsWith("--tw-") ? `renamed "--twk-${key.slice(5)}" in 0.7` : '"--twk-accent", say'}) — skipped`,
580
+ ambiguousPath: (key, n) =>
581
+ `set("${key}") is ambiguous — ${n} controls read as that path (a key holding "." beside a folder); rename one`,
292
582
  noPath: (key) => `set("${key}") — no control at that path`,
293
583
  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`),
584
+ ambiguousKey: (key, n, eg) =>
585
+ `set("${key}") is ambiguous — ${n} controls share that key; use a dotted path (e.g. "${eg}")`,
586
+ springDur: (key, v, shown) =>
587
+ `"${key}": a spring visualDuration of ${v} s is past the editor's 0.01–10 s, so it uses ${shown} s`,
588
+ springKept: (key, k, d, m) =>
589
+ `"${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`,
590
+ noControl: (key, group) =>
591
+ group
592
+ ? `set("${key}") ignored — it's a folder/tabs group; set its children instead`
593
+ : `set("${key}") ignored — no control has that key`,
594
+ };
595
+ const notice = (kind: string, args: unknown[]) => {
596
+ const say = NOTICES[kind];
597
+ if (say) console.warn(`[tweaks] ${say(...args)}`);
298
598
  };
299
- const notice = (kind: string, args: unknown[]) => { const say = NOTICES[kind]; if (say) console.warn(`[tweaks] ${say(...args)}`); };
300
599
  sayNow(notice);
301
600
 
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 };
601
+ export {
602
+ escapeReverts,
603
+ dragPane,
604
+ badGradient,
605
+ notice,
606
+ decimalStep,
607
+ unknownType,
608
+ numField,
609
+ dragGesture,
610
+ svgEl,
611
+ cssVar,
612
+ accentColor,
613
+ selectAllOnFocus,
614
+ grabSurface,
615
+ boxFrac,
616
+ fitCanvas,
617
+ triggerRow,
618
+ springCurve,
619
+ springSpan,
620
+ springRun,
621
+ springFromTime,
622
+ timeFromSpring,
623
+ arrowStep,
624
+ sayWhenKeysStop,
625
+ SPRING_DUR,
626
+ };