tweakit 0.5.2 → 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 (120) hide show
  1. package/AGENTS.md +32 -30
  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-S2BOXQBM.js +2 -0
  7. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  9. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  11. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  13. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  14. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  15. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  16. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  17. package/dist/tweaks/color-UGYMAF3W.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-GY47HIQA.js +2 -0
  21. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  22. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  23. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  24. package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
  25. package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
  26. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  27. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  28. package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
  29. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  30. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  31. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  32. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  33. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  34. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  35. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  36. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  37. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  38. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  39. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  40. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  41. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  42. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  43. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  44. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  45. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  46. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  47. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  48. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  49. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  50. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  51. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  52. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  53. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  54. package/dist/tweaks.css +1 -1
  55. package/dist/tweaks.js +7 -3
  56. package/dist/tweaks.js.map +6 -0
  57. package/dist/types/tweaks/easing.d.ts +8 -11
  58. package/dist/types/tweaks/enhance.d.ts +3 -2
  59. package/dist/types/tweaks/panel.d.ts +6 -4
  60. package/dist/types/tweaks/schema.d.ts +4 -8
  61. package/dist/types/tweaks/standalone.d.ts +3 -3
  62. package/dist/types/tweaks/types.d.ts +499 -116
  63. package/package.json +30 -5
  64. package/src/tweaks/auto.ts +16 -0
  65. package/src/tweaks/clip.ts +156 -0
  66. package/src/tweaks/controls/basic.ts +727 -0
  67. package/src/tweaks/controls/bezier.ts +204 -0
  68. package/src/tweaks/controls/color.ts +861 -0
  69. package/src/tweaks/controls/gradient.ts +419 -0
  70. package/src/tweaks/controls/image.ts +555 -0
  71. package/src/tweaks/controls/interval.ts +189 -0
  72. package/src/tweaks/controls/monitor.ts +211 -0
  73. package/src/tweaks/controls/motion.ts +316 -0
  74. package/src/tweaks/controls/plot.ts +440 -0
  75. package/src/tweaks/controls/point.ts +208 -0
  76. package/src/tweaks/controls/shadow.ts +156 -0
  77. package/src/tweaks/controls/sides.ts +196 -0
  78. package/src/tweaks/controls/spring.ts +308 -0
  79. package/src/tweaks/controls/tabs.ts +68 -0
  80. package/src/tweaks/core.ts +9 -0
  81. package/src/tweaks/easing.ts +118 -0
  82. package/src/tweaks/enhance.ts +35 -0
  83. package/src/tweaks/feedback.ts +218 -0
  84. package/src/tweaks/fold.ts +33 -0
  85. package/src/tweaks/heavy.ts +626 -0
  86. package/src/tweaks/icons.ts +26 -0
  87. package/src/tweaks/image-store.ts +189 -0
  88. package/src/tweaks/lazy.ts +63 -0
  89. package/src/tweaks/markup.ts +317 -0
  90. package/src/tweaks/panel.ts +1693 -0
  91. package/src/tweaks/presets.ts +85 -0
  92. package/src/tweaks/schema.ts +351 -0
  93. package/src/tweaks/shadow-root.ts +178 -0
  94. package/src/tweaks/shared.ts +1137 -0
  95. package/src/tweaks/single.ts +22 -0
  96. package/src/tweaks/standalone.ts +179 -0
  97. package/src/tweaks/types.ts +1027 -0
  98. package/src/tweaks/undo.ts +104 -0
  99. package/src/tweaks.css +965 -0
  100. package/src/wide-gamut.ts +658 -0
  101. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  102. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  103. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  104. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  105. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  106. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  107. package/dist/tweaks/fold-UGN7TXGX.js +0 -1
  108. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  109. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  110. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  111. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  112. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  113. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  114. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  115. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  116. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  117. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  118. package/dist/tweaks/sides-FH227Z54.js +0 -1
  119. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  120. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,189 @@
1
+ // ── Interval / range slider — dual-handle [lo,hi]. Lazy.
2
+ import {
3
+ el,
4
+ txt,
5
+ clamp,
6
+ words,
7
+ roundToStep,
8
+ stepPrecision,
9
+ gridEnds,
10
+ normalizeRange,
11
+ rangeStep,
12
+ overlapsText,
13
+ wireHoverClass,
14
+ onReady,
15
+ onLive,
16
+ registerControl,
17
+ rubberBand,
18
+ rubberRelease,
19
+ } from "../shared.js";
20
+ import { dragGesture } from "../heavy.js";
21
+ import type { OnChange } from "../shared.js";
22
+ import type { Meta } from "../schema.js";
23
+ import type { Control } from "../types.js";
24
+
25
+ function createInterval(meta: Meta, onChange: OnChange): Control {
26
+ const label = meta.label;
27
+ const t0 = +(meta.value && meta.value[0]),
28
+ t1 = +(meta.value && meta.value[1]);
29
+ const lo0 = Number.isFinite(t0) ? t0 : 0,
30
+ hi0 = Number.isFinite(t1) ? t1 : 1;
31
+ // normalizeRange, the slider's guard: an absent or non-finite bound (data-min="abc") derives from the values, each
32
+ // end widening it, for the shorthand and the verbose form alike: [20, 80] spans 0–240, not 20–80 (which pinned both
33
+ // handles).
34
+ let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [lo0, hi0], label);
35
+ [min, max] = gridEnds(min, max, step); // the slider's rule: no value past an off-grid bound
36
+ const decimals = stepPrecision(step);
37
+ const q = (v: number) => roundToStep(v, min, step);
38
+ let lo = 0,
39
+ hi = 0;
40
+ // ordered, on the grid, in bounds
41
+ const put = (a: number, b: number) => {
42
+ lo = clamp(q(Math.min(a, b)), min, max);
43
+ hi = clamp(q(Math.max(a, b)), min, max);
44
+ };
45
+ // Missing tuple entries fall back to the bounds: value:[5] gives [5, max], not [5, NaN].
46
+ put(Number.isFinite(t0) ? t0 : min, Number.isFinite(t1) ? t1 : max);
47
+
48
+ const wrap = el("div", "twk-slider-wrap");
49
+ const track = el("div", "twk-slider twk-interval");
50
+ const fill = el("div", "twk-slider-fill");
51
+ const hLo = el("div", "twk-slider-handle"),
52
+ hHi = el("div", "twk-slider-handle");
53
+ const labelEl = txt("span", "twk-slider-label", label);
54
+ const valueEl = el("span", "twk-slider-value");
55
+ track.append(fill, hLo, hHi, labelEl, valueEl);
56
+ wrap.append(track);
57
+
58
+ const tr = words();
59
+ (
60
+ [
61
+ ["intervalMin", "{label} minimum", hLo],
62
+ ["intervalMax", "{label} maximum", hHi],
63
+ ] as const
64
+ ).forEach(([k, d, h]) => {
65
+ h.tabIndex = 0;
66
+ h.setAttribute("role", "slider");
67
+ h.setAttribute("aria-label", tr(k, d, { label }));
68
+ h.setAttribute("aria-valuemin", String(min));
69
+ h.setAttribute("aria-valuemax", String(max));
70
+ });
71
+
72
+ const pctOf = (v: number) => ((v - min) / (max - min || 1)) * 100;
73
+ // stay inset at the extremes, like the slider handle
74
+ const handleLeft = (pct: number) => `clamp(5px, calc(${pct}% - 1.5px), calc(100% - 9px))`;
75
+ const render = () => {
76
+ const a = pctOf(lo),
77
+ b = pctOf(hi);
78
+ fill.style.left = a + "%";
79
+ fill.style.width = Math.max(0, b - a) + "%";
80
+ hLo.style.left = handleLeft(a);
81
+ hHi.style.left = handleLeft(b);
82
+ valueEl.textContent = `${lo.toFixed(decimals)} – ${hi.toFixed(decimals)}`;
83
+ hLo.setAttribute("aria-valuenow", String(lo));
84
+ hHi.setAttribute("aria-valuenow", String(hi));
85
+ // Value-dodge (overlapsText), per handle: one sliding under the label or the readout yields so the text stays
86
+ // legible.
87
+ const trackW = wrap.offsetWidth;
88
+ if (trackW) {
89
+ // The label's room is what the dual readout leaves, measured: 12px label inset + 8px gap + the readout's right
90
+ // inset.
91
+ labelEl.style.maxWidth = Math.max(0, trackW - valueEl.offsetWidth - 12 - 8 - 10) + "px";
92
+ const dodges = (pct: number) => overlapsText(labelEl, valueEl, (pct / 100) * trackW - 1.5, 3);
93
+ hLo.classList.toggle("is-dodge", dodges(pctOf(lo)));
94
+ hHi.classList.toggle("is-dodge", dodges(pctOf(hi)));
95
+ }
96
+ };
97
+ render();
98
+
99
+ const emit = () => onChange([lo, hi]);
100
+ const setLo = (v: number) => {
101
+ lo = clamp(Math.min(q(v), hi), min, max);
102
+ render();
103
+ };
104
+ const setHi = (v: number) => {
105
+ hi = clamp(Math.max(q(v), lo), min, max);
106
+ render();
107
+ };
108
+
109
+ let rect: DOMRect | null = null,
110
+ scale = 1,
111
+ active: "lo" | "hi" | null = null;
112
+ // Divide out any ancestor CSS transform (rect is visual px, offsetWidth layout px), as the single slider does.
113
+ const valFromX = (x: number) => {
114
+ const native = wrap.offsetWidth || rect.width;
115
+ const p = clamp((x - rect.left) / scale / native, 0, 1);
116
+ return clamp(min + p * (max - min), min, max);
117
+ };
118
+ const grab = (x: number) => {
119
+ const v = valFromX(x);
120
+ if (!active) active = v < lo ? "lo" : v > hi ? "hi" : Math.abs(v - lo) <= Math.abs(v - hi) ? "lo" : "hi";
121
+ active === "lo" ? setLo(v) : setHi(v);
122
+ emit();
123
+ };
124
+ dragGesture(track, {
125
+ onDown: (e) => {
126
+ e.preventDefault();
127
+ rect = wrap.getBoundingClientRect();
128
+ scale = rect.width / (wrap.offsetWidth || rect.width);
129
+ track.style.transition = ""; // a re-press mid spring-home stretches 1:1
130
+ track.classList.add("is-active", "is-dragging");
131
+ active = null;
132
+ grab(e.clientX);
133
+ // preventDefault killed click-to-focus; focus without a ring
134
+ (active === "lo" ? hLo : hHi).focus({ focusVisible: false });
135
+ },
136
+ onMove: (e) => {
137
+ grab(e.clientX);
138
+ rubberBand(track, rect, e.clientX);
139
+ },
140
+ onEnd: () => {
141
+ rubberRelease(track);
142
+ rect = null;
143
+ active = null;
144
+ track.classList.remove("is-active", "is-dragging");
145
+ },
146
+ });
147
+ wireHoverClass(track, render); // re-run the dodge with the real track width on hover
148
+ // Recompute once layout and fonts settle (a web-font swap shifts the widths the dodge measures) and on track-width
149
+ // change.
150
+ onReady(render);
151
+ // twk-reflow: a tab page revealing this re-measures it
152
+ onLive(
153
+ track,
154
+ [
155
+ [window, "resize"],
156
+ [window, "twk-reflow"],
157
+ ],
158
+ render,
159
+ );
160
+
161
+ // Home/End snap a handle to its neighbor-or-limit, so the two can't cross.
162
+ const page = (max - min) / 10 || step * 10; // PageUp/Down: a tenth of the range, as the slider jumps
163
+ const onKey = (which: "lo" | "hi") => (e: KeyboardEvent) => {
164
+ const nv = which === "lo" ? rangeStep(e, lo, step, min, hi, page) : rangeStep(e, hi, step, lo, max, page);
165
+ if (nv == null) return;
166
+ e.preventDefault();
167
+ which === "lo" ? setLo(nv) : setHi(nv);
168
+ emit();
169
+ };
170
+ hLo.addEventListener("keydown", onKey("lo"));
171
+ hHi.addEventListener("keydown", onKey("hi"));
172
+
173
+ return {
174
+ el: wrap,
175
+ set: (v: unknown) => {
176
+ if (Array.isArray(v)) {
177
+ const a = +v[0],
178
+ b = +v[1];
179
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return;
180
+ put(a, b);
181
+ render();
182
+ }
183
+ },
184
+ get: () => [lo, hi],
185
+ range: [min, max],
186
+ };
187
+ }
188
+
189
+ registerControl("interval", createInterval);
@@ -0,0 +1,211 @@
1
+ // ── Monitor + FPS graph — live sparkline / readout. Lazy; registers both types.
2
+ import { el, txt, clamp, blade, json, registerControl, words } 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 = (
8
+ ctx: CanvasRenderingContext2D,
9
+ node: Element,
10
+ w: number,
11
+ h: number,
12
+ samples: number[],
13
+ start: number,
14
+ frac: (s: number) => number,
15
+ ) => {
16
+ const N = samples.length;
17
+ ctx.clearRect(0, 0, w, h);
18
+ ctx.strokeStyle = accentColor(node);
19
+ ctx.lineWidth = 1.5;
20
+ ctx.lineJoin = "round";
21
+ ctx.beginPath();
22
+ let pen = false;
23
+ for (let k = 0; k < N; k++) {
24
+ const s = samples[(start + k) % N];
25
+ if (Number.isNaN(s)) continue;
26
+ const x = (k / (N - 1)) * w,
27
+ y = h - clamp(frac(s), 0, 1) * h;
28
+ pen ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), (pen = true));
29
+ }
30
+ ctx.stroke();
31
+ };
32
+
33
+ // Manual, not onLive: these blades idle through the built-but-not-mounted window, which onLive would read as gone.
34
+ const watchReflow = (fn: () => void) => {
35
+ for (const t of ["resize", "twk-reflow"]) window.addEventListener(t, fn);
36
+ return () => {
37
+ for (const t of ["resize", "twk-reflow"]) window.removeEventListener(t, fn);
38
+ };
39
+ };
40
+
41
+ // ── Monitor — a number as a sparkline or a readout, a string as a buffer of the last few values; read on an interval
42
+ // or, `perFrame` (the FPS graph), once per animation frame. ──
43
+ function createMonitor(meta: Meta, perFrame = false) {
44
+ const get: (now?: number) => unknown = typeof meta.get === "function" ? meta.get : () => meta.value;
45
+ // NaN would make setInterval a 0 ms busy-poll
46
+ const interval = Math.max(30, Number.isFinite(+meta.interval) ? +meta.interval : 200);
47
+ let probe: unknown;
48
+ try {
49
+ probe = get();
50
+ } catch {}
51
+ const isNum = typeof probe === "number";
52
+ const graph = meta.view === "graph" || (isNum && meta.graph !== false && meta.view !== "text" && meta.rows == null);
53
+
54
+ const wrap = el("div", "twk-fps twk-monitor");
55
+ const val = txt("span", "twk-fps-val", "—");
56
+ wrap.append(txt("span", "twk-fps-label", meta.label ?? words()("monitor", "Monitor")), val);
57
+
58
+ let timer = 0,
59
+ raf = 0,
60
+ unwatch = () => {},
61
+ wasConnected = false;
62
+ // toFixed throws past 100; a missing value reads as a dash.
63
+ const decimals = Number.isFinite(+meta.decimals) ? Math.min(20, Math.max(0, Math.floor(+meta.decimals))) : 2;
64
+ const fmt = (v: unknown) =>
65
+ v == null
66
+ ? "—"
67
+ : typeof v === "number"
68
+ ? Number.isInteger(v) || !Number.isFinite(v)
69
+ ? String(v)
70
+ : v.toFixed(decimals)
71
+ : typeof v === "object"
72
+ ? json(v)
73
+ : String(v);
74
+ // Also the blade's `destroy`: a panel torn down before it ever connected can never self-stop below.
75
+ const stop = () => {
76
+ if (timer) clearInterval(timer);
77
+ timer = 0;
78
+ if (raf) cancelAnimationFrame(raf);
79
+ raf = 0;
80
+ unwatch();
81
+ };
82
+ // Never-mounted-yet idles the tick; only a mount-then-remove, or destroy(), stops it. The next frame is booked before
83
+ // the read, so a stop() inside the tick cancels it.
84
+ const poll = (fn: (v: unknown) => void) => {
85
+ const tick = (now?: number) => {
86
+ if (perFrame) raf = requestAnimationFrame(tick);
87
+ if (!wrap.isConnected) {
88
+ if (wasConnected) stop();
89
+ return;
90
+ }
91
+ wasConnected = true;
92
+ let v: unknown;
93
+ try {
94
+ v = get(now);
95
+ } catch {
96
+ return;
97
+ }
98
+ fn(v);
99
+ };
100
+ if (perFrame) raf = requestAnimationFrame(tick);
101
+ else timer = setInterval(tick, interval);
102
+ };
103
+
104
+ if (!graph && meta.rows) {
105
+ val.remove();
106
+ // A negative or NaN rows spun the trim loop forever.
107
+ const rows = Math.max(1, Math.floor(+meta.rows) || 1);
108
+ const buf = el("pre", "twk-monitor-buffer");
109
+ buf.style.setProperty("--twk-monitor-rows", rows);
110
+ wrap.append(buf);
111
+ const lines: string[] = [];
112
+ poll((v) => {
113
+ lines.push(fmt(v));
114
+ while (lines.length > rows) lines.shift();
115
+ buf.textContent = lines.join("\n");
116
+ });
117
+ return blade(wrap, stop);
118
+ }
119
+ const readout = (v: unknown) => {
120
+ val.textContent = fmt(v);
121
+ };
122
+ if (!graph) {
123
+ poll(readout);
124
+ return blade(wrap, stop);
125
+ }
126
+
127
+ const canvas = document.createElement("canvas");
128
+ canvas.className = "twk-fps-canvas";
129
+ wrap.append(canvas);
130
+ const ctx = canvas.getContext("2d");
131
+ // No 2D context (a headless DOM, a blocked canvas) degrades to the text readout.
132
+ if (!ctx) {
133
+ poll(readout);
134
+ return blade(wrap, stop);
135
+ }
136
+ const N = 80,
137
+ samples: number[] = new Array(N).fill(NaN);
138
+ let idx = 0,
139
+ w = 0,
140
+ h = 0;
141
+ const onResize = () => {
142
+ [w, h] = fitCanvas(canvas, ctx, 2);
143
+ };
144
+ unwatch = watchReflow(onResize);
145
+ const draw = () => {
146
+ if (!w) onResize(); // measured 0 while detached: fit on the first live draw
147
+ if (!w) return;
148
+ let lo = meta.min,
149
+ hi = meta.max;
150
+ if (lo == null || hi == null) {
151
+ let mn = Infinity,
152
+ mx = -Infinity;
153
+ // an Infinity sample would blank the auto-range
154
+ for (const s of samples)
155
+ if (Number.isFinite(s)) {
156
+ if (s < mn) mn = s;
157
+ if (s > mx) mx = s;
158
+ }
159
+ if (mn === Infinity) {
160
+ mn = 0;
161
+ mx = 1;
162
+ } else if (mn === mx) {
163
+ mn -= 0.5;
164
+ mx += 0.5;
165
+ }
166
+ const pad = (mx - mn) * 0.1;
167
+ if (lo == null) lo = mn - pad;
168
+ if (hi == null) hi = mx + pad;
169
+ }
170
+ const span = hi - lo || 1;
171
+ strokeSeries(ctx, wrap, w, h, samples, idx, (s) => (s - lo) / span);
172
+ };
173
+ // a non-finite sample shows in the readout, not the graph
174
+ poll((v) => {
175
+ readout(v);
176
+ if (typeof v === "number" && Number.isFinite(v)) {
177
+ samples[idx] = v;
178
+ idx = (idx + 1) % N;
179
+ draw();
180
+ }
181
+ });
182
+ return blade(wrap, stop);
183
+ }
184
+
185
+ // ── FPS graph — each frame's timestamp becomes the rate since the last (none on the first or for the probe), 0–120. ──
186
+ const frameRate = () => {
187
+ let last = 0;
188
+ return (now?: number) => {
189
+ if (!(now! > 0)) return undefined;
190
+ const fps = last ? 1000 / (now! - last) : undefined;
191
+ last = now!;
192
+ return fps;
193
+ };
194
+ };
195
+ // ??: an explicit "" label renders none
196
+ const createFps = (meta: Meta) =>
197
+ createMonitor(
198
+ {
199
+ ...meta,
200
+ label: meta.label ?? words()("fps", "FPS"),
201
+ get: frameRate(),
202
+ view: "graph",
203
+ min: 0,
204
+ max: 120,
205
+ decimals: 0,
206
+ },
207
+ true,
208
+ );
209
+
210
+ registerControl("fpsgraph", createFps);
211
+ registerControl("monitor", (meta: Meta) => createMonitor(meta)); // the registry passes (meta, onChange), not perFrame