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,104 @@
1
+ // ── Interval / range slider — dual-handle [lo,hi]. Lazy.
2
+ import { el, txt, clamp, roundToStep, stepPrecision, gridEnds, normalizeRange, rangeStep, overlapsText, wireHoverClass, onReady, onLive, registerControl, rubberBand, rubberRelease } from "../shared.js";
3
+ import { dragGesture } from "../heavy.js";
4
+ import type { OnChange } from "../shared.js";
5
+ import type { Meta } from "../schema.js";
6
+ import type { Control } from "../types.js";
7
+
8
+ function createInterval(meta: Meta, onChange: OnChange): Control {
9
+ const label = meta.label;
10
+ const t0 = +(meta.value && meta.value[0]), t1 = +(meta.value && meta.value[1]);
11
+ const lo0 = Number.isFinite(t0) ? t0 : 0, hi0 = Number.isFinite(t1) ? t1 : 1;
12
+ // normalizeRange, the slider's guard: an absent or non-finite bound (data-min="abc") derives from the values, each end
13
+ // widening it, for the shorthand and the verbose form alike: [20, 80] spans 0–240, not 20–80 (which pinned both handles).
14
+ let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [lo0, hi0], label);
15
+ [min, max] = gridEnds(min, max, step); // the slider's rule: no value past an off-grid bound
16
+ const decimals = stepPrecision(step);
17
+ const q = (v: number) => roundToStep(v, min, step);
18
+ let lo = 0, hi = 0;
19
+ const put = (a: number, b: number) => { lo = clamp(q(Math.min(a, b)), min, max); hi = clamp(q(Math.max(a, b)), min, max); }; // ordered, on the grid, in bounds
20
+ // Missing tuple entries fall back to the bounds: value:[5] gives [5, max], not [5, NaN].
21
+ put(Number.isFinite(t0) ? t0 : min, Number.isFinite(t1) ? t1 : max);
22
+
23
+ const wrap = el("div", "tw-slider-wrap");
24
+ const track = el("div", "tw-slider tw-interval");
25
+ const fill = el("div", "tw-slider-fill");
26
+ const hLo = el("div", "tw-slider-handle"), hHi = el("div", "tw-slider-handle");
27
+ const labelEl = txt("span", "tw-slider-label", label);
28
+ const valueEl = el("span", "tw-slider-value");
29
+ track.append(fill, hLo, hHi, labelEl, valueEl);
30
+ wrap.append(track);
31
+
32
+ ([["minimum", hLo], ["maximum", hHi]] as const).forEach(([lab, h]) => {
33
+ h.tabIndex = 0; h.setAttribute("role", "slider"); h.setAttribute("aria-label", `${label} ${lab}`);
34
+ h.setAttribute("aria-valuemin", String(min)); h.setAttribute("aria-valuemax", String(max));
35
+ });
36
+
37
+ const pctOf = (v: number) => ((v - min) / ((max - min) || 1)) * 100;
38
+ const handleLeft = (pct: number) => `clamp(5px, calc(${pct}% - 1.5px), calc(100% - 9px))`; // stay inset at the extremes, like the slider handle
39
+ const render = () => {
40
+ const a = pctOf(lo), b = pctOf(hi);
41
+ fill.style.left = a + "%"; fill.style.width = Math.max(0, b - a) + "%";
42
+ hLo.style.left = handleLeft(a); hHi.style.left = handleLeft(b);
43
+ valueEl.textContent = `${lo.toFixed(decimals)} – ${hi.toFixed(decimals)}`;
44
+ hLo.setAttribute("aria-valuenow", String(lo)); hHi.setAttribute("aria-valuenow", String(hi));
45
+ // Value-dodge (overlapsText), per handle: one sliding under the label or the readout yields so the text stays legible.
46
+ const trackW = wrap.offsetWidth;
47
+ if (trackW) {
48
+ // The label's room is what the dual readout leaves, measured: 12px label inset + 8px gap + the readout's right inset.
49
+ labelEl.style.maxWidth = Math.max(0, trackW - valueEl.offsetWidth - 12 - 8 - 10) + "px";
50
+ const dodges = (pct: number) => overlapsText(labelEl, valueEl, (pct / 100) * trackW - 1.5, 3);
51
+ hLo.classList.toggle("is-dodge", dodges(pctOf(lo)));
52
+ hHi.classList.toggle("is-dodge", dodges(pctOf(hi)));
53
+ }
54
+ };
55
+ render();
56
+
57
+ const emit = () => onChange([lo, hi]);
58
+ const setLo = (v: number) => { lo = clamp(Math.min(q(v), hi), min, max); render(); };
59
+ const setHi = (v: number) => { hi = clamp(Math.max(q(v), lo), min, max); render(); };
60
+
61
+ let rect: DOMRect | null = null, scale = 1, active: "lo" | "hi" | null = null;
62
+ // Divide out any ancestor CSS transform (rect is visual px, offsetWidth layout px), as the single slider does.
63
+ const valFromX = (x: number) => { const native = wrap.offsetWidth || rect.width; const p = clamp((x - rect.left) / scale / native, 0, 1); return clamp(min + p * (max - min), min, max); };
64
+ const grab = (x: number) => {
65
+ const v = valFromX(x);
66
+ if (!active) active = v < lo ? "lo" : v > hi ? "hi" : (Math.abs(v - lo) <= Math.abs(v - hi) ? "lo" : "hi");
67
+ active === "lo" ? setLo(v) : setHi(v); emit();
68
+ };
69
+ dragGesture(track, {
70
+ onDown: (e) => {
71
+ e.preventDefault();
72
+ rect = wrap.getBoundingClientRect(); scale = rect.width / (wrap.offsetWidth || rect.width);
73
+ track.style.transition = ""; // a re-press mid spring-home stretches 1:1
74
+ track.classList.add("is-active", "is-dragging"); active = null;
75
+ grab(e.clientX);
76
+ (active === "lo" ? hLo : hHi).focus({ focusVisible: false }); // preventDefault killed click-to-focus; focus without a ring
77
+ },
78
+ onMove: (e) => { grab(e.clientX); rubberBand(track, rect, e.clientX); },
79
+ onEnd: () => { rubberRelease(track); rect = null; active = null; track.classList.remove("is-active", "is-dragging"); },
80
+ });
81
+ wireHoverClass(track, render); // re-run the dodge with the real track width on hover
82
+ // Recompute once layout and fonts settle (a web-font swap shifts the widths the dodge measures) and on track-width change.
83
+ onReady(render);
84
+ onLive(track, [[window, "resize"], [window, "tw-reflow"]], render); // tw-reflow: a tab page revealing this re-measures it
85
+
86
+ // Home/End snap a handle to its neighbor-or-limit, so the two can't cross.
87
+ const page = (max - min) / 10 || step * 10; // PageUp/Down: a tenth of the range, as the slider jumps
88
+ const onKey = (which: "lo" | "hi") => (e: KeyboardEvent) => {
89
+ const nv = which === "lo" ? rangeStep(e, lo, step, min, hi, page) : rangeStep(e, hi, step, lo, max, page);
90
+ if (nv == null) return;
91
+ e.preventDefault(); which === "lo" ? setLo(nv) : setHi(nv); emit();
92
+ };
93
+ hLo.addEventListener("keydown", onKey("lo")); hHi.addEventListener("keydown", onKey("hi"));
94
+
95
+ return {
96
+ el: wrap,
97
+ set: (v: unknown) => { if (Array.isArray(v)) { const a = +v[0], b = +v[1]; if (!Number.isFinite(a) || !Number.isFinite(b)) return; put(a, b); render(); } },
98
+ get: () => [lo, hi],
99
+ range: [min, max],
100
+ };
101
+ }
102
+
103
+ registerControl("interval", createInterval);
104
+
@@ -0,0 +1,105 @@
1
+ // ── Monitor + FPS graph — live sparkline / readout. Lazy; registers both types.
2
+ import { el, txt, clamp, blade, json, registerControl } from "../shared.js";
3
+ import { fitCanvas, accentColor } from "../heavy.js";
4
+ import type { Meta } from "../schema.js";
5
+
6
+ // NaN means no sample yet: the pen lifts rather than drawing a false zero.
7
+ const strokeSeries = (ctx: CanvasRenderingContext2D, node: Element, w: number, h: number, samples: number[], start: number, frac: (s: number) => number) => {
8
+ const N = samples.length;
9
+ ctx.clearRect(0, 0, w, h);
10
+ ctx.strokeStyle = accentColor(node);
11
+ ctx.lineWidth = 1.5; ctx.lineJoin = "round"; ctx.beginPath();
12
+ let pen = false;
13
+ for (let k = 0; k < N; k++) {
14
+ const s = samples[(start + k) % N];
15
+ if (Number.isNaN(s)) continue;
16
+ const x = (k / (N - 1)) * w, y = h - clamp(frac(s), 0, 1) * h;
17
+ pen ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), pen = true);
18
+ }
19
+ ctx.stroke();
20
+ };
21
+
22
+ // Manual, not onLive: these blades idle through the built-but-not-mounted window, which onLive would read as gone.
23
+ const watchReflow = (fn: () => void) => {
24
+ for (const t of ["resize", "tw-reflow"]) window.addEventListener(t, fn);
25
+ return () => { for (const t of ["resize", "tw-reflow"]) window.removeEventListener(t, fn); };
26
+ };
27
+
28
+ // ── Monitor — a number as a sparkline or a readout, a string as a buffer of the last few values; read on an interval
29
+ // or, `perFrame` (the FPS graph), once per animation frame. ──
30
+ function createMonitor(meta: Meta, perFrame = false) {
31
+ const get: (now?: number) => unknown = typeof meta.get === "function" ? meta.get : () => meta.value;
32
+ const interval = Math.max(30, Number.isFinite(+meta.interval) ? +meta.interval : 200); // NaN would make setInterval a 0 ms busy-poll
33
+ let probe: unknown; try { probe = get(); } catch {}
34
+ const isNum = typeof probe === "number";
35
+ const graph = meta.view === "graph" || (isNum && meta.graph !== false && meta.view !== "text" && meta.rows == null);
36
+
37
+ const wrap = el("div", "tw-fps tw-monitor");
38
+ const val = txt("span", "tw-fps-val", "—");
39
+ wrap.append(txt("span", "tw-fps-label", meta.label ?? "Monitor"), val);
40
+
41
+ let timer = 0, raf = 0, unwatch = () => {}, wasConnected = false;
42
+ // toFixed throws past 100; a missing value reads as a dash.
43
+ const decimals = Number.isFinite(+meta.decimals) ? Math.min(20, Math.max(0, Math.floor(+meta.decimals))) : 2;
44
+ const fmt = (v: unknown) => (v == null ? "—" : typeof v === "number" ? (Number.isInteger(v) || !Number.isFinite(v) ? String(v) : v.toFixed(decimals)) : typeof v === "object" ? json(v) : String(v));
45
+ // Also the blade's `destroy`: a panel torn down before it ever connected can never self-stop below.
46
+ const stop = () => { if (timer) clearInterval(timer); timer = 0; if (raf) cancelAnimationFrame(raf); raf = 0; unwatch(); };
47
+ // Never-mounted-yet idles the tick; only a mount-then-remove, or destroy(), stops it. The next frame is booked before
48
+ // the read, so a stop() inside the tick cancels it.
49
+ const poll = (fn: (v: unknown) => void) => {
50
+ const tick = (now?: number) => {
51
+ if (perFrame) raf = requestAnimationFrame(tick);
52
+ if (!wrap.isConnected) { if (wasConnected) stop(); return; }
53
+ wasConnected = true;
54
+ let v: unknown; try { v = get(now); } catch { return; }
55
+ fn(v);
56
+ };
57
+ if (perFrame) raf = requestAnimationFrame(tick); else timer = setInterval(tick, interval);
58
+ };
59
+
60
+ if (!graph && meta.rows) {
61
+ val.remove();
62
+ // A negative or NaN rows spun the trim loop forever.
63
+ const rows = Math.max(1, Math.floor(+meta.rows) || 1);
64
+ const buf = el("pre", "tw-monitor-buffer"); buf.style.setProperty("--tw-monitor-rows", rows);
65
+ wrap.append(buf);
66
+ const lines: string[] = [];
67
+ poll((v) => { lines.push(fmt(v)); while (lines.length > rows) lines.shift(); buf.textContent = lines.join("\n"); });
68
+ return blade(wrap, stop);
69
+ }
70
+ const readout = (v: unknown) => { val.textContent = fmt(v); };
71
+ if (!graph) { poll(readout); return blade(wrap, stop); }
72
+
73
+ const canvas = document.createElement("canvas"); canvas.className = "tw-fps-canvas";
74
+ wrap.append(canvas);
75
+ const ctx = canvas.getContext("2d");
76
+ // No 2D context (a headless DOM, a blocked canvas) degrades to the text readout.
77
+ if (!ctx) { poll(readout); return blade(wrap, stop); }
78
+ const N = 80, samples: number[] = new Array(N).fill(NaN);
79
+ let idx = 0, w = 0, h = 0;
80
+ const onResize = () => { [w, h] = fitCanvas(canvas, ctx, 2); };
81
+ unwatch = watchReflow(onResize);
82
+ const draw = () => {
83
+ if (!w) onResize(); // measured 0 while detached: fit on the first live draw
84
+ if (!w) return;
85
+ let lo = meta.min, hi = meta.max;
86
+ if (lo == null || hi == null) {
87
+ let mn = Infinity, mx = -Infinity;
88
+ for (const s of samples) if (Number.isFinite(s)) { if (s < mn) mn = s; if (s > mx) mx = s; } // an Infinity sample would blank the auto-range
89
+ if (mn === Infinity) { mn = 0; mx = 1; } else if (mn === mx) { mn -= 0.5; mx += 0.5; }
90
+ const pad = (mx - mn) * 0.1;
91
+ if (lo == null) lo = mn - pad; if (hi == null) hi = mx + pad;
92
+ }
93
+ const span = (hi - lo) || 1;
94
+ strokeSeries(ctx, wrap, w, h, samples, idx, (s) => (s - lo) / span);
95
+ };
96
+ poll((v) => { readout(v); if (typeof v === "number" && Number.isFinite(v)) { samples[idx] = v; idx = (idx + 1) % N; draw(); } }); // a non-finite sample shows in the readout, not the graph
97
+ return blade(wrap, stop);
98
+ }
99
+
100
+ // ── FPS graph — each frame's timestamp becomes the rate since the last (none on the first or for the probe), 0–120. ──
101
+ const frameRate = () => { let last = 0; return (now?: number) => { if (!(now! > 0)) return undefined; const fps = last ? 1000 / (now! - last) : undefined; last = now!; return fps; }; };
102
+ const createFps = (meta: Meta) => createMonitor({ ...meta, label: meta.label ?? "FPS", get: frameRate(), view: "graph", min: 0, max: 120, decimals: 0 }, true); // ??: an explicit "" label renders none
103
+
104
+ registerControl("fpsgraph", createFps);
105
+ registerControl("monitor", (meta: Meta) => createMonitor(meta)); // the registry passes (meta, onChange), not perFrame
@@ -0,0 +1,141 @@
1
+ // ── Motion — a transition's timing: an easing curve with a duration, or a spring, in one popover over one value. The
2
+ // value carries the CSS it resolves to — `duration` in ms and an `easing` string, cubic-bezier() or a sampled linear(). Lazy. ──
3
+ import { el, txt, clamp, isObj, createSegmented, getControl, popover, registerControl, REDUCE_MOTION } from "../shared.js";
4
+ import { numField, triggerRow, svgEl, springCurve, springRun, timeFromSpring, notice, SPRING_DUR } from "../heavy.js";
5
+ import { KEYWORDS } from "../easing.js";
6
+ import type { OnChange } from "../shared.js";
7
+ import type { Meta } from "../schema.js";
8
+ import type { Control, MotionValue, MotionInput } from "../types.js";
9
+
10
+ type Mode = "easing" | "spring";
11
+ type Curve = [number, number, number, number];
12
+ interface SpringOut { stiffness: number; damping: number; mass: number; visualDuration?: number; bounce?: number }
13
+
14
+ const DUR_DEF = 300, DUR_MAX = 10000, STAGE_PAD = 16; // ms; the stage's side padding (tweaks.css)
15
+ const BOUNCE_MAX = 1; // Motion's whole range: its damping-ratio floor of 0.05 keeps a bounce of 1 settling
16
+ const has = (x: unknown) => (typeof x === "number" ? Number.isFinite(x) : typeof x === "string" && x.trim() !== "" && Number.isFinite(+x)); // a number, or a numeric string — not `true`, `[]` or " "
17
+ const curveOf = (c: unknown): Curve | null => {
18
+ if (typeof c === "string") { const k = c === "linear" ? [0, 0, 1, 1] : KEYWORDS[c]; return k ? (k.slice() as Curve) : null; } // the CSS keywords, linear's identity included; null-prototype, so "constructor" is not one
19
+ if (Array.isArray(c) && c.length === 4) { const m = c.map(Number); return m.every(Number.isFinite) ? (m as Curve) : null; }
20
+ return null;
21
+ };
22
+ const springKeys = (v: MotionInput) => has(v.stiffness) || has(v.damping) || has(v.mass) || has(v.visualDuration) || has(v.bounce);
23
+ // Physics keys map to the visualDuration + bounce Motion would resolve to them (timeFromSpring). An explicit duration/bounce wins.
24
+ const springTime = (v: MotionInput) => {
25
+ const out: { mode: "time"; visualDuration?: number; bounce?: number } = { mode: "time" };
26
+ if (has(v.stiffness)) { const k = +v.stiffness!, m = has(v.mass) ? +v.mass! : 1, t = timeFromSpring(k, has(v.damping) ? +v.damping! : 2 * Math.sqrt(k * m), m); out.visualDuration = t.visualDuration; if (has(v.damping)) out.bounce = clamp(t.bounce, -1, BOUNCE_MAX); }
27
+ if (has(v.visualDuration)) out.visualDuration = +v.visualDuration!;
28
+ if (has(v.bounce)) out.bounce = clamp(+v.bounce!, -1, BOUNCE_MAX);
29
+ return out;
30
+ };
31
+ // A stiffness and damping the host wrote, kept as written rather than resolved through the editor's time pair, which
32
+ // rounds and clamps: unless a time pair rides along that isn't the one they resolve to (this control's own value back).
33
+ const exactSpring = (v: MotionInput): SpringOut | null => {
34
+ if (!has(v.stiffness) || !has(v.damping)) return null;
35
+ const k = +v.stiffness!, d = +v.damping!, m = has(v.mass) ? +v.mass! : 1;
36
+ if (!(k > 0 && d >= 0 && m > 0)) return null;
37
+ const t = timeFromSpring(k, d, m), off = (a: unknown, b: number) => has(a) && Math.abs(+a! - b) > 1e-6 * Math.max(1, Math.abs(b));
38
+ return off(v.visualDuration, t.visualDuration) || off(v.bounce, t.bounce) ? null : { stiffness: k, damping: d, mass: m, ...t };
39
+ };
40
+ const modeOf = (v: MotionInput, keep: Mode): Mode => (v.mode === "spring" || v.mode === "easing" ? v.mode : springKeys(v) ? "spring" : curveOf(v.curve) ? "easing" : keep); // a curve that doesn't parse is no reason to switch
41
+
42
+ function createMotion(meta: Meta, onChange: OnChange): Control {
43
+ const Bezier = getControl("cubicbezier"), Spring = getControl("spring");
44
+ if (!Bezier || !Spring) throw new Error("[tweaks] motion: the bezier and spring controls are not loaded");
45
+ const init: MotionInput = isObj(meta.value) ? meta.value : {};
46
+ let mode = modeOf(init, "easing");
47
+ let curve = curveOf(init.curve) ?? (KEYWORDS.ease.slice() as Curve), dur = has(init.duration) ? clamp(+init.duration!, 0, DUR_MAX) : DUR_DEF;
48
+
49
+ // ── Row — label, a mini curve, a summary. ──
50
+ const { root, trigger, right } = triggerRow("tw-motion", meta.label ?? "Motion"); // ??: an explicit "" label renders none
51
+ const chip = el("span", "tw-trigger-chip tw-motion-chip");
52
+ const chipSvg = svgEl("svg"); chipSvg.setAttribute("viewBox", "0 0 100 100"); chipSvg.setAttribute("preserveAspectRatio", "none");
53
+ const chipPath = svgEl("path"); chipSvg.append(chipPath); chip.append(chipSvg);
54
+ const valueEl = el("span", "tw-trigger-value");
55
+ right.append(chip, valueEl);
56
+
57
+ // ── Popover — the mode switch, the preview, one editor at a time. ──
58
+ const pop = el("div", "tw-color-pop tw-motion-pop"), body = el("div", "tw-motion-body"); pop.append(body);
59
+ const bezier = Bezier({ ...meta, type: "cubicbezier", label: "", value: curve.slice() } as Meta, (v) => { curve = v as Curve; update(); emit(); });
60
+ curve = bezier.get() as Curve; // as the editor fitted it, so no invalid cubic-bezier()
61
+ // Host-written physics the editor can't draw exactly: it shows the nearest, clamped here so it doesn't warn as well.
62
+ let exact: SpringOut | null = null, said = false;
63
+ const editorTime = (inp: MotionInput) => {
64
+ exact = exactSpring(inp);
65
+ if (!exact) return springTime(inp);
66
+ const vd = clamp(exact.visualDuration!, SPRING_DUR[0], SPRING_DUR[1]), z = exact.damping / (2 * Math.sqrt(exact.stiffness * exact.mass));
67
+ if ((vd !== exact.visualDuration || z < 0.05 || z > 20) && !said) { said = true; notice("springKept", [meta.key, exact.stiffness, exact.damping, exact.mass]); }
68
+ return { mode: "time" as const, visualDuration: vd, bounce: exact.bounce };
69
+ };
70
+ // Time mode only; its Mode row is hidden (tweaks.css). An edit there drops the kept physics.
71
+ const spring = Spring({ ...meta, type: "spring", label: "", value: editorTime(init) } as Meta, () => { exact = null; update(); emit(); });
72
+ const springNow = () => exact ?? (spring.get() as SpringOut);
73
+ const durFld = numField({ label: "Duration", value: dur, step: 10, min: 0, max: DUR_MAX, unit: "ms" }, (v) => { dur = clamp(v, 0, DUR_MAX); update(); emit(); });
74
+ dur = durFld.get(); // as the field snapped it
75
+ const easingPane = el("div", "tw-motion-pane"), springPane = el("div", "tw-motion-pane"), durRow = el("div", "tw-fields");
76
+ durRow.append(durFld.el); easingPane.append(bezier.el, durRow); springPane.append(spring.el);
77
+ const stage = el("div", "tw-motion-stage"), box = el("div", "tw-motion-box"); stage.append(box);
78
+ const modeSeg = createSegmented([{ value: "easing", label: "Easing" }, { value: "spring", label: "Spring" }], mode, (m: unknown) => { if ((m === "easing" || m === "spring") && m !== mode) { mode = m; showMode(); update(); emit(); } }, "Motion type");
79
+ const typeRow = el("div", "tw-row"); typeRow.append(txt("span", "tw-row-label", "Type"), modeSeg.el);
80
+ body.append(typeRow, easingPane, springPane, stage);
81
+ root.append(pop);
82
+
83
+ const value = (): MotionValue => {
84
+ if (mode === "easing") return { mode, curve: curve.slice() as Curve, duration: dur, easing: `cubic-bezier(${curve.join(", ")})` };
85
+ // The run lasts until the spring is at rest (the drawn curve stops at 2.2 s): springRun.
86
+ const s = springNow();
87
+ return { mode, ...s, ...springRun(s.stiffness, s.damping, s.mass) };
88
+ };
89
+ const emit = () => onChange(value());
90
+
91
+ // Web Animations, no frame loop to own. A click or an open flips direction and restarts; an edit lets a run in flight
92
+ // finish — a handle drag fires per pointer move, and restarting each strobed the box.
93
+ let far = false, anim: Animation | null = null;
94
+ const replay = (flip: boolean) => {
95
+ if (flip) far = !far; else if (anim && anim.playState === "running") return;
96
+ const v = value();
97
+ const travel = Math.max(0, stage.clientWidth - box.offsetWidth - 2 * STAGE_PAD); // measured now: the stage is 0 wide until the pop opens
98
+ const from = `translateX(${far ? 0 : travel}px)`, to = `translateX(${far ? travel : 0}px)`;
99
+ if (REDUCE_MOTION.matches || typeof box.animate !== "function") { box.style.transform = to; return; }
100
+ anim?.cancel();
101
+ const frames = [{ transform: from }, { transform: to }];
102
+ try { anim = box.animate(frames, { duration: v.duration, easing: v.easing, fill: "forwards" }); }
103
+ catch { anim = box.animate(frames, { duration: v.duration, fill: "forwards" }); } // no linear() support: a plain run rather than nothing
104
+ };
105
+ stage.addEventListener("click", () => replay(true));
106
+ stage.tabIndex = 0; stage.setAttribute("role", "button"); stage.setAttribute("aria-label", "Replay the motion");
107
+ stage.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); replay(true); } });
108
+
109
+ // The chip's mini curve, y up, scaled so an overshoot still fits.
110
+ const curvePath = () => {
111
+ let pts: [number, number][];
112
+ if (mode === "easing") { const [x1, y1, x2, y2] = curve; pts = []; for (let i = 0; i <= 24; i++) { const t = i / 24, u = 1 - t, a = 3 * u * u * t, b = 3 * u * t * t, c = t * t * t; pts.push([a * x1 + b * x2 + c, a * y1 + b * y2 + c]); } }
113
+ else { const s = springNow(); pts = springCurve(s.stiffness, s.damping, s.mass, 32).map((y, i) => [i / 31, y]); }
114
+ const hi = Math.max(1, ...pts.map((p) => p[1])), lo = Math.min(0, ...pts.map((p) => p[1]));
115
+ return pts.map(([x, y], i) => `${i ? "L" : "M"}${(10 + x * 80).toFixed(1)},${(90 - ((y - lo) / (hi - lo)) * 80).toFixed(1)}`).join(" ");
116
+ };
117
+ // The revealed editor built at 0×0 and onLive may have dropped its resize listener while the pop was detached, so
118
+ // re-point it at its own value, which redraws at real size.
119
+ const resync = () => { if (mode === "easing") bezier.set(curve.slice()); else spring.set(spring.get()); };
120
+ const showMode = () => { easingPane.style.display = mode === "easing" ? "" : "none"; springPane.style.display = mode === "spring" ? "" : "none"; resync(); };
121
+ // The summary shows the authored duration; a spring's emitted run (its settle) is longer.
122
+ const summary = () => (mode === "easing" ? `Easing · ${dur} ms` : `Spring · ${Math.round((springNow().visualDuration ?? 0) * 1000)} ms`);
123
+ const update = () => { chipPath.setAttribute("d", curvePath()); valueEl.textContent = summary(); replay(false); };
124
+
125
+ popover(root, trigger, pop, { fallbackH: 380, onOpen: () => { modeSeg.set(mode); resync(); replay(true); }, initialFocus: () => modeSeg.el.querySelector<HTMLElement>('[tabindex="0"]') }); // hidden until open: measure now; focus the checked option
126
+ showMode(); update();
127
+
128
+ // Set/restore: either mode's shape or a bare keyword; derived fields (easing, a spring's duration) are ignored.
129
+ const set = (v: unknown) => {
130
+ const inp: MotionInput | null = typeof v === "string" ? { curve: v } : Array.isArray(v) && v.length === 4 ? { curve: v as Curve } : isObj(v) ? v : null; // a bare 4-array is the bezier's own shape
131
+ if (!inp) return;
132
+ mode = modeOf(inp, mode);
133
+ const c = curveOf(inp.curve); if (c) { bezier.set(c.slice()); curve = bezier.get() as Curve; } // read back fitted
134
+ if (mode === "easing" && has(inp.duration)) { durFld.set(clamp(+inp.duration!, 0, DUR_MAX)); dur = durFld.get(); } // read back snapped
135
+ if (springKeys(inp)) spring.set(editorTime(inp));
136
+ modeSeg.set(mode); showMode(); update();
137
+ };
138
+ return { el: root, set, get: value, destroy: () => spring.destroy && spring.destroy() };
139
+ }
140
+
141
+ registerControl("motion", createMotion);
@@ -0,0 +1,220 @@
1
+ // ── Plot — graph y=f(x) with a safe expression evaluator (compileExpr). Lazy.
2
+ import { el, txt, clamp, onReady, onLive, quietFocus, registerControl } from "../shared.js";
3
+ import { svgEl } from "../heavy.js";
4
+ import type { OnChange, Built } from "../shared.js";
5
+ import type { Meta } from "../schema.js";
6
+ import type { Control } from "../types.js";
7
+
8
+ /** A compiled expression: y for an x. */
9
+ type Expr = (x: number) => number;
10
+ /** The tokens the expression lexer emits. */
11
+ type Tok = { t: "num"; v: number } | { t: "id" | "op"; v: string } | { t: "lp" | "rp" | "comma"; v?: undefined };
12
+
13
+ // ── A safe expression evaluator: recursive descent, not eval/Function, so a typed formula does only arithmetic and
14
+ // whitelisted Math calls. Null-prototype tables, so "constructor" can't parse as an identifier. ──
15
+ const PLOT_FUNCS: Record<string, (...args: number[]) => number> = Object.assign(Object.create(null), {
16
+ sin: Math.sin, cos: Math.cos, tan: Math.tan, asin: Math.asin, acos: Math.acos,
17
+ atan: Math.atan, atan2: Math.atan2, sinh: Math.sinh, cosh: Math.cosh, tanh: Math.tanh,
18
+ sqrt: Math.sqrt, cbrt: Math.cbrt, abs: Math.abs, exp: Math.exp, log: Math.log, ln: Math.log,
19
+ log10: Math.log10, log2: Math.log2, floor: Math.floor, ceil: Math.ceil, round: Math.round,
20
+ sign: Math.sign, trunc: Math.trunc, min: Math.min, max: Math.max, pow: Math.pow, hypot: Math.hypot,
21
+ mod: (a: number, b: number) => a % b, clamp: (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi),
22
+ });
23
+ const PLOT_VARIADIC = ["min", "max", "hypot"]; // called empty these return a number (Infinity, 0), so a slip drew nothing and flagged nothing
24
+ const PLOT_CONSTS: Record<string, number> = Object.assign(Object.create(null), { pi: Math.PI, e: Math.E, tau: Math.PI * 2, phi: (1 + Math.sqrt(5)) / 2 });
25
+ /** The compiled expression, or null on any parse error. */
26
+ export function compileExpr(src: unknown): Expr | null {
27
+ const r = parseExpr(src); return typeof r === "function" ? r : null;
28
+ }
29
+ /** The compiled expression, or why it doesn't parse; the reason names the fix where there is one ("write sin(…)"). */
30
+ export function parseExpr(src: unknown): Expr | string {
31
+ if (typeof src !== "string" || !src.trim()) return "type an expression in x";
32
+ if (src.length > 512) return "too long: 512 characters at most"; // also bounds recursion depth, so no RangeError mid-parse
33
+ const s = src, toks: Tok[] = [];
34
+ // `5.` reads as 5, as `.5` reads as 0.5. An e is an exponent only with a digit right after it (and its sign): `2e+1` is
35
+ // 20, while `2e+x` is the constant, 2·e + x, as `2e` alone is 2·e. Scientific notation never has a name for its power.
36
+ const numRe = /(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y, idRe = /[A-Za-z_]\w*/y;
37
+ let i = 0;
38
+ while (i < s.length) {
39
+ const ch = s[i];
40
+ if (ch === " " || ch === "\t" || ch === "\n") { i++; continue; }
41
+ if ((ch >= "0" && ch <= "9") || (ch === "." && s[i + 1] >= "0" && s[i + 1] <= "9")) {
42
+ numRe.lastIndex = i; const m = numRe.exec(s); if (!m || m.index !== i) return `unexpected "${ch}"`;
43
+ toks.push({ t: "num", v: parseFloat(m[0]) }); i = numRe.lastIndex; continue;
44
+ }
45
+ if ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_") {
46
+ idRe.lastIndex = i; const m = idRe.exec(s); toks.push({ t: "id", v: m[0] }); i = idRe.lastIndex; continue;
47
+ }
48
+ if (ch === "*" && s[i + 1] === "*") { toks.push({ t: "op", v: "^" }); i += 2; continue; }
49
+ if ("+-*/%^".includes(ch)) { toks.push({ t: "op", v: ch }); i++; continue; }
50
+ if (ch === "(") { toks.push({ t: "lp" }); i++; continue; }
51
+ if (ch === ")") { toks.push({ t: "rp" }); i++; continue; }
52
+ if (ch === ",") { toks.push({ t: "comma" }); i++; continue; }
53
+ return `unexpected "${ch}"`;
54
+ }
55
+ let p = 0, why = "";
56
+ const fail = (m: string): null => { why = why || m; return null; }; // the innermost reason wins; every caller unwinds on null
57
+ const peek = () => toks[p];
58
+ const eat = (t?: Tok["t"]) => { const tok = toks[p]; if (!tok || (t && tok.t !== t)) return null; p++; return tok; };
59
+ const add = (): Expr | null => {
60
+ let a = mul(); if (!a) return null;
61
+ while (peek() && peek().t === "op" && (peek().v === "+" || peek().v === "-")) {
62
+ const op = eat("op").v, b = mul(); if (!b) return null; const aa = a;
63
+ a = op === "+" ? (x) => aa(x) + b(x) : (x) => aa(x) - b(x);
64
+ }
65
+ return a;
66
+ };
67
+ const mul = (): Expr | null => {
68
+ let a = unary(); if (!a) return null;
69
+ for (let t = peek(); t; t = peek()) {
70
+ // `2x`, `2pi`, `3(x+1)`, `pi x`, `3x(x)`, `(x)(x)`, `sin(x)cos(x)`: a number, x, a constant or ")" directly before
71
+ // a name or "(" multiplies, as on paper. x and the constants are never functions, so `x(2)` is 2x; `xy` stays one
72
+ // (unknown) name, and a number after an operand (`2 3`, `(x)2`) asks for an operator. It binds as `*` does: `2x^2` is 2·x².
73
+ const prev = toks[p - 1].t, implied = (prev === "num" || prev === "id" || prev === "rp") && (t.t === "id" || t.t === "lp");
74
+ if (!implied && !(t.t === "op" && (t.v === "*" || t.v === "/" || t.v === "%"))) break;
75
+ const op = implied ? "*" : eat("op").v, b = unary(); if (!b) return null; const aa = a;
76
+ a = op === "*" ? (x) => aa(x) * b(x) : op === "/" ? (x) => aa(x) / b(x) : (x) => aa(x) % b(x);
77
+ }
78
+ return a;
79
+ };
80
+ const unary = (): Expr | null => {
81
+ if (peek() && peek().t === "op" && (peek().v === "-" || peek().v === "+")) {
82
+ const op = eat("op").v, a = unary(); if (!a) return null;
83
+ return op === "-" ? (x) => -a(x) : a;
84
+ }
85
+ return pow();
86
+ };
87
+ const pow = (): Expr | null => {
88
+ const a = atom(); if (!a) return null;
89
+ if (peek() && peek().t === "op" && peek().v === "^") { eat("op"); const b = unary(); if (!b) return null; return (x) => Math.pow(a(x), b(x)); }
90
+ return a;
91
+ };
92
+ const atom = (): Expr | null => {
93
+ const tok = peek(); if (!tok) return null;
94
+ if (tok.t === "num") { eat("num"); return () => tok.v; }
95
+ if (tok.t === "lp") { eat("lp"); const e = add(); if (!e || !eat("rp")) return null; return e; }
96
+ if (tok.t === "id") {
97
+ eat("id"); const name = tok.v.toLowerCase(), fn = PLOT_FUNCS[name];
98
+ if (peek() && peek().t === "lp" && (fn || (name !== "x" && !(name in PLOT_CONSTS)))) { // `x(2)`: a product, in mul()
99
+ if (!fn) return fail(`unknown function "${tok.v}"`);
100
+ eat("lp"); const argv: Expr[] = [];
101
+ if (peek() && peek().t !== "rp") { do { const a = add(); if (!a) return null; argv.push(a); } while (eat("comma")); }
102
+ if (!eat("rp")) return null;
103
+ if (!argv.length && PLOT_VARIADIC.includes(name)) return fail(`${name}() needs at least one value`);
104
+ return (x) => fn(...argv.map((a) => a(x)));
105
+ }
106
+ if (fn) {
107
+ // `sin x`, `cos 2x`: a one-argument function takes a bare x, constant or number-times-name, as a calculator does.
108
+ // Anything longer (`sin x^2`, `sin -x`, `sin 2 cos x`) reads two ways, so it asks for the parentheses instead of guessing.
109
+ const a = fn.length === 1 ? bare() : null, n = peek();
110
+ if (!a || (n && (n.t === "op" ? n.v === "^" : n.t !== "rp" && n.t !== "comma"))) return fail(`write ${name}(…)`);
111
+ return (x) => fn(a(x));
112
+ }
113
+ if (name === "x") return (x) => x;
114
+ if (name in PLOT_CONSTS) { const c = PLOT_CONSTS[name]; return () => c; }
115
+ return fail(`unknown name "${tok.v}"`); // an identifier that isn't x, a constant, or a known function
116
+ }
117
+ return null;
118
+ };
119
+ const named = (t: Tok) => t && t.t === "id" && !PLOT_FUNCS[t.v.toLowerCase()];
120
+ const bare = (): Expr | null => {
121
+ const t = peek();
122
+ if (t && t.t === "num") { eat("num"); if (!named(peek())) return () => t.v; const b = atom(); return b && ((x) => t.v * b(x)); }
123
+ return named(t) ? atom() : null;
124
+ };
125
+ const tree = add(), left = toks[p];
126
+ if (tree && !left) return tree;
127
+ if (why) return why;
128
+ // A parse that stopped short, or tokens left over: say where.
129
+ const last = toks[p - 1], unclosed = toks.slice(0, p).reduce((d, t) => d + (t.t === "lp" ? 1 : t.t === "rp" ? -1 : 0), 0);
130
+ if (!tree && last && last.t === "op") return `"${last.v}" needs a value on each side`;
131
+ if (!left) return unclosed > 0 ? 'missing ")"' : "not a valid expression";
132
+ return left.t === "num" ? `missing an operator before ${left.v}`
133
+ : left.t === "rp" ? (last && last.t === "lp" ? "nothing inside the parentheses" : 'unmatched ")"')
134
+ : left.t === "comma" ? '"," outside a function\'s parentheses'
135
+ : left.t === "op" ? `"${left.v}" needs a value on each side` : "not a valid expression";
136
+ }
137
+
138
+ // ── Plot — y = f(x) across a range with an editable expression, or a fixed `fn`. Y auto-ranges unless yMin/yMax are
139
+ // given; the line breaks across asymptotes. ──
140
+ function createPlot(meta: Meta, onChange: OnChange): Control {
141
+ // Non-finite bounds take the defaults, an inverted pair swaps, an equal pair pads out (÷0 drew nothing); a degenerate y-range falls back to auto.
142
+ let xMin = Number(meta.xMin ?? meta.min), xMax = Number(meta.xMax ?? meta.max); // min/max are the verbose form's aliases
143
+ if (!Number.isFinite(xMin)) xMin = -10;
144
+ if (!Number.isFinite(xMax)) xMax = 10;
145
+ if (xMax < xMin) { const t = xMin; xMin = xMax; xMax = t; }
146
+ if (xMax === xMin) { xMin -= 1; xMax += 1; }
147
+ const samples = Math.max(2, Math.min(4096, (meta.samples | 0) || 256)); // capped: 1e8 samples per keystroke froze the tab
148
+ const fixedY = Number.isFinite(meta.yMin) && Number.isFinite(meta.yMax) && +meta.yMax > +meta.yMin;
149
+ const fn = typeof meta.fn === "function" ? meta.fn : null;
150
+ const editable = meta.editable !== false && !fn;
151
+ let expr = meta.expr != null ? String(meta.expr) : fn ? "" : "sin(x)"; // a sine to start from
152
+ let compiled = fn || parseExpr(expr); // a string is the reason it doesn't parse
153
+
154
+ const root = el("div", "tw-plot");
155
+ if (meta.label) root.append(txt("div", "tw-plot-label", meta.label));
156
+ const graph = el("div", "tw-plot-graph");
157
+ const svg = svgEl("svg", "tw-plot-svg"); svg.setAttribute("preserveAspectRatio", "none");
158
+ const axisX = svgEl("line", "tw-plot-axis"), axisY = svgEl("line", "tw-plot-axis"), curve = svgEl("path", "tw-plot-curve");
159
+ svg.append(axisX, axisY, curve); graph.append(svg); root.append(graph);
160
+
161
+ let input: HTMLInputElement | null = null;
162
+ if (editable) {
163
+ const field = el("div", "tw-plot-field");
164
+ input = el("input", "tw-plot-input"); input.type = "text"; input.value = expr; input.spellcheck = false;
165
+ input.autocapitalize = "off"; quietFocus(input); /* click-to-edit stays ringless; Tab rings the field */ input.setAttribute("aria-label", `${meta.label || "Plot"} — expression in x`); input.setAttribute("aria-invalid", "false");
166
+ field.append(txt("span", "tw-plot-fx", "y ="), input); root.append(field); // txt, not el(…, html): el's third arg is innerHTML
167
+ }
168
+
169
+ const PAD = 6;
170
+ const draw = () => {
171
+ // Flag invalid before the size guard, so a hidden tab page still reports a bad expression.
172
+ const f = typeof compiled === "function" ? compiled : null, ok = !!f;
173
+ root.classList.toggle("is-invalid", editable && !ok);
174
+ if (input) { input.setAttribute("aria-invalid", ok ? "false" : "true"); input.title = ok ? "" : String(compiled); } // the title says why, and names the fix
175
+ const r = graph.getBoundingClientRect(), W = r.width, H = r.height; if (W < 2) return;
176
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
177
+ const ys: number[] = []; let yLo = Infinity, yHi = -Infinity;
178
+ if (ok) for (let k = 0; k < samples; k++) {
179
+ const x = xMin + (xMax - xMin) * (k / (samples - 1));
180
+ let y: number; try { y = f(x); } catch { y = NaN; }
181
+ ys.push(y);
182
+ if (Number.isFinite(y)) { if (y < yLo) yLo = y; if (y > yHi) yHi = y; }
183
+ }
184
+ let lo = meta.yMin, hi = meta.yMax;
185
+ if (!fixedY) {
186
+ if (!Number.isFinite(yLo) || yLo === yHi) { const c = Number.isFinite(yLo) ? yLo : 0; lo = c - 1; hi = c + 1; }
187
+ else { const pad = (yHi - yLo) * 0.12; lo = yLo - pad; hi = yHi + pad; }
188
+ }
189
+ const xPx = (x: number) => PAD + ((x - xMin) / (xMax - xMin)) * (W - 2 * PAD);
190
+ const yPx = (y: number) => (H - PAD) - ((y - lo) / (hi - lo)) * (H - 2 * PAD);
191
+ const axis = (ln: Built<SVGLineElement>, ax: number, ay: number, bx: number, by: number, show: boolean) => { ln.style.display = show ? "" : "none"; if (show) { ln.setAttribute("x1", ax); ln.setAttribute("y1", ay); ln.setAttribute("x2", bx); ln.setAttribute("y2", by); } };
192
+ axis(axisX, PAD, yPx(0), W - PAD, yPx(0), lo <= 0 && hi >= 0);
193
+ axis(axisY, xPx(0), PAD, xPx(0), H - PAD, xMin <= 0 && xMax >= 0);
194
+ let d = "", pen = false;
195
+ if (ok) for (let k = 0; k < samples; k++) {
196
+ const y = ys[k];
197
+ if (!Number.isFinite(y)) { pen = false; continue; }
198
+ const px = xPx(xMin + (xMax - xMin) * (k / (samples - 1))), py = clamp(yPx(y), -1e4, 1e4);
199
+ d += (pen ? "L" : "M") + px.toFixed(1) + "," + py.toFixed(1) + " "; pen = true;
200
+ }
201
+ curve.setAttribute("d", d);
202
+ };
203
+
204
+ if (editable) input.addEventListener("input", () => {
205
+ expr = input.value; compiled = parseExpr(expr); // a reason string on a parse error → draw flags is-invalid
206
+ draw(); onChange(expr);
207
+ });
208
+
209
+ onReady(draw);
210
+ onLive(root, [[window, "resize"], [window, "tw-reflow"]], draw); // tw-reflow: a tab page revealing this re-measures it
211
+
212
+ return {
213
+ el: root,
214
+ get: () => expr,
215
+ set: (v: unknown) => { if (v == null) return; expr = String(v); if (input) input.value = expr; if (!fn) compiled = parseExpr(expr); draw(); }, // an invalid expression flags, as typing does
216
+ };
217
+ }
218
+
219
+ registerControl("plot", createPlot);
220
+