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,525 @@
1
+ /* Tweaks — shared building blocks: DOM + math helpers, theming, the popover shell and
2
+ * the control registry. core.js and the lazy controls/*.js import what they each use. */
3
+ import type { Control, Option, Panel, Theme } from "./types.js";
4
+ import type { Meta } from "./schema.js";
5
+
6
+ // ── Internal types ──
7
+ /** A control's change callback. */
8
+ type OnChange = (value: unknown) => void;
9
+ /** The resolved `--tw-*` custom-property map a theme turns into (resolveTheme). */
10
+ type ThemeVars = Record<string, string>;
11
+ /** A panel root; panel.ts hangs its live theme, edit hook and ready promise on the node. */
12
+ interface PanelEl extends HTMLElement { _twTheme?: ThemeVars | null; ready?: Promise<Panel> }
13
+ /** A node applyThemeVars has written to; remembers which properties, to clear them next time. */
14
+ interface ThemedEl extends HTMLElement { _twAppliedVars?: string[] | null }
15
+ /** A single-select radio button carrying its option's real value (dataset stringifies). */
16
+ interface RadioBtn extends HTMLButtonElement { _twVal?: string | number }
17
+ /** setAttribute() / style.setProperty() taking a number as well as a string (the DOM
18
+ * stringifies either). Type-level only. */
19
+ interface Stringifying { setAttribute(name: string, value: string | number): void; style: { setProperty(name: string, value: string | number | null, priority?: string): void } }
20
+ type Built<E extends Element> = E & Stringifying;
21
+ /** The labeled numeric field's spec (numField). */
22
+ interface NumSpec { label: string; value?: unknown; min?: unknown; max?: unknown; step?: unknown; soft?: boolean; row?: boolean; unit?: string } // unit: muted suffix, also in the accessible name
23
+ /** The numeric field's handle: set() coerces and fits, get() reads the fitted number. */
24
+ interface NumField { el: HTMLDivElement; set(v: number | string): void; get(): number; range: [number, number] }
25
+ /** The popover shell's handle (popover). */
26
+ interface Popover { open(): void; close(): void; isOpen(): boolean; reflow(): void }
27
+ /** A control constructor: normalized meta in, handle out. The registry holds every one under
28
+ * the loose form; a lookup names the shape it expects — `getControl<TabsControl>("tabs")`. */
29
+ type ControlCtor<M = Meta, C = Control> = (meta: M, onChange?: OnChange) => C;
30
+
31
+ // ── helpers ──
32
+ // A word starts at a camel hump or where a capital opens a lowercase word ("myURLParser" → "My URL Parser").
33
+ // A key opening on one lowercase letter and capitals, or on an Apple OS, is a name written as meant ("sRGB", "iOS",
34
+ // "macOS", not "S RGB"), kept whole and the rest split after it ("sRGBGamut" → "sRGB Gamut"); a word ending in an
35
+ // acronym splits as any other ("showFPS" → "Show FPS").
36
+ const humanize = (s: string) => s.replace(/([a-z])(?=[A-Z])|([A-Z\d])(?=[A-Z][a-z])/g, "$1$2 ").replace(/^./, (c) => c.toUpperCase()).trim();
37
+ const titleCase = (s: string) => { const m = /^(?:[a-z][A-Z]{2,}?|(?:mac|tv|watch|iPad|vision)OS)(?=[A-Z][a-z]|[^A-Za-z]|$)/.exec(s); return m ? `${m[0]} ${humanize(s.slice(m[0].length))}`.trim() : humanize(s); };
38
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
39
+ const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === "object";
40
+ // Hex or any CSS color function: routes a schema string to the color control.
41
+ const isColorStr = (v: unknown) => typeof v === "string" && /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|(oklch|oklab|rgba?|hsla?|hwb|lab|lch|color)\()/i.test(v.trim());
42
+ // toFixed() throws past 100 digits, and a readout past 20 decimals is noise.
43
+ const MAX_FIXED = 20;
44
+ const stepPrecision = (step: number) => {
45
+ const t = String(step);
46
+ // String(1e-7) has no "." — read the exponent, or fine steps round to whole numbers.
47
+ const e = /e-(\d+)$/i.exec(t);
48
+ if (e) return Math.min(MAX_FIXED, Number(e[1]) + (t.split("e")[0].split(".")[1] || "").length);
49
+ const i = t.indexOf(".");
50
+ return i === -1 ? 0 : Math.min(MAX_FIXED, t.length - i - 1);
51
+ };
52
+ // Round to the min-anchored step grid. The anchor is rounded to the step's decimals too: a
53
+ // finer min put every grid point half a decimal off, and each re-round walked the value up.
54
+ const roundToStep = (v: number, min: number, step: number) => {
55
+ if (!(step > 0)) return v;
56
+ const p = stepPrecision(step), a = Number((+min).toFixed(p));
57
+ const n = Math.round((v - a) / step); if (!Number.isFinite(n)) return v; // a huge value over a tiny step overflows; keep the raw
58
+ return Number((a + n * step).toFixed(p));
59
+ };
60
+ // The reachable ends of a [min, max] step grid, so a clamp never lands off-grid; a range too
61
+ // narrow to hold a grid point pins to min. ±Infinity = open.
62
+ const gridEnds = (min: number, max: number, step: number, anchor = min): [number, number] => {
63
+ const r = (v: number) => roundToStep(v, anchor, step);
64
+ let lo = Number.isFinite(min) ? r(min) : -Infinity, hi = Number.isFinite(max) ? r(max) : Infinity;
65
+ // A millionth of a step is float noise, not overshoot: 0.1 * 3's min rounds to 0.3, a hair under it, and trimmed to 0.31.
66
+ const eps = step * 1e-6;
67
+ if (lo < min - eps) lo = r(lo + step);
68
+ if (hi > max + eps) hi = r(hi - step);
69
+ return lo > hi ? [min, min] : [lo, hi];
70
+ };
71
+ // By the span, then finer, a tenth at a time and at most twice, until each value the range was built around sits on the
72
+ // grid from min: a coarser grid snapped the default off what was written (37 over 0–110 became 40). A value finer still
73
+ // (3.14159) rounds to the last of those. A range across zero counts 0 among them, so -45's -135–135 steps by 1, not by
74
+ // 10 from -135 (…, -5, 5, …), which stepped over 0. roundToStep anchors the grid at min rounded to the step's decimals,
75
+ // so min must keep its own decimals too (0.25 over 0.1 anchored at 0.3, and 1.25 came back 1.2); max joins them when a
76
+ // step within reach fits it, else the grid trims it as before.
77
+ const inferStep = (min: number, max: number, ...vals: number[]) => {
78
+ const range = max - min;
79
+ if (min < 0 && max > 0) vals.push(0);
80
+ // A span under the finest step the bands reach (0.0001) starts at a hundredth of its own decade, or [5e-6, 0, 1e-5] was 0–0.
81
+ const base = range > 0 && range < 1e-4 ? 10 ** Math.floor(Math.log10(range)) / 100 : range <= 1 ? 0.01 : range <= 10 ? 0.1 : range <= 100 ? 1 : 10;
82
+ const on = (n: number) => !Number.isFinite(n) || Math.abs(n - Math.round(n)) <= 1e-6;
83
+ const fits = (step: number, vs: number[]) => on(min / Math.min(step, 1)) && vs.every((v) => on((v - min) / step));
84
+ const finest = (vs: number[]) => { let step = base; for (let i = 0; i < 2 && !fits(step, vs); i++) step /= 10; return step; };
85
+ const withMax = finest([...vals, max]);
86
+ return fits(withMax, [...vals, max]) ? withMax : finest(vals);
87
+ };
88
+ // The range a value implies when no bound is given, widened by each value (defaultRange), and its step. A range mirrored
89
+ // about zero steps like its positive half (-0.5 by 0.01, like 0.5), not by its doubled span, and its ends keep 0 on the grid.
90
+ const derivedRange = (...vals: number[]) => {
91
+ const rs = (vals.length ? vals : [0]).map(defaultRange);
92
+ const min = Math.min(...rs.map((r) => r[0])), max = Math.max(...rs.map((r) => r[1]));
93
+ return { min, max, step: min < 0 && min === -max ? inferStep(0, max, max, ...vals.map(Math.abs)) : inferStep(min, max, ...vals) };
94
+ };
95
+ // A console notice for a mistake. Its words live in the lazy heavy chunk (heavy.ts NOTICES), so a panel that makes none
96
+ // never downloads them; it lands a moment later, which a console doesn't mind.
97
+ // Once the heavy chunk is in (always on the single-file build), a notice prints at once, so code that reads the console
98
+ // right after the set() that caused it sees it; before, it waits for the chunk.
99
+ let says: ((kind: string, args: unknown[]) => void) | null = null;
100
+ const sayNow = (f: typeof says) => { says = f; };
101
+ const notice = (kind: string, ...args: unknown[]) => { if (says) says(kind, args); else import("./heavy.js").then((m) => m.notice(kind, args), () => {}); };
102
+ // Normalize a slider-style range: an absent or non-finite bound derives from the values, an inverted pair swaps (with a
103
+ // note, given the control's label), and only then does an absent or unusable step infer, so a NaN min can't hand it a NaN
104
+ // span. Every range source funnels here.
105
+ const normalizeRange = (rawMin: unknown, rawMax: unknown, rawStep: unknown, vals: number[] = [], label?: string) => {
106
+ const given = (n: unknown) => n != null && n !== "" && Number.isFinite(+n!);
107
+ vals = vals.filter(Number.isFinite);
108
+ const d = derivedRange(...vals);
109
+ let min = given(rawMin) ? +rawMin! : d.min, max = given(rawMax) ? +rawMax! : d.max, step = +rawStep!;
110
+ // One bound written at or past the values' own other end: that end derives from the written bound, as a bare number its
111
+ // size would, and steps a span past it when that's still no range, so [5, 20] slides 20–60 and [0.5, 1] 1–2, not 15–20 or 1–1.
112
+ if (given(rawMin) !== given(rawMax) && !(max > min)) { const up = given(rawMin), b = up ? min : max, r = derivedRange(b); if (up) max = r.max > b ? r.max : b + r.max - r.min; else min = r.min < b ? r.min : b - r.max + r.min; }
113
+ if (max < min) { if (label != null) notice("swap", label, min, max); const t = min; min = max; max = t; }
114
+ if (!(step > 0) || step > max - min) step = given(rawMin) || given(rawMax) ? inferStep(min, max, ...vals) : d.step;
115
+ return { min, max, step };
116
+ };
117
+ // One keyboard model for every 1-D range surface: arrows step (⇧ = ×10), Home/End snap to
118
+ // the ends, PageUp/Down jump by `page`. Null when the key isn't the range's to handle.
119
+ const rangeStep = (e: KeyboardEvent, cur: number, step: number, lo: number, hi: number, page: number | null = null) => {
120
+ const coarse = e.shiftKey ? 10 : 1;
121
+ switch (e.key) {
122
+ case "ArrowRight": case "ArrowUp": return cur + step * coarse;
123
+ case "ArrowLeft": case "ArrowDown": return cur - step * coarse;
124
+ case "PageUp": return page != null ? cur + page : null;
125
+ case "PageDown": return page != null ? cur - page : null;
126
+ case "Home": return lo;
127
+ case "End": return hi;
128
+ }
129
+ return null;
130
+ };
131
+ // Does a handle's pixel span [hx, hx+hw] overlap the label or value text? `m` is a near-miss buffer.
132
+ const overlapsText = (labelEl: HTMLElement, valueEl: HTMLElement, hx: number, hw: number, m = 0) => {
133
+ const ll = labelEl.offsetLeft, lr = ll + labelEl.offsetWidth;
134
+ const vl = valueEl.offsetLeft, vr = vl + valueEl.offsetWidth;
135
+ return (hx < lr && hx + hw > ll) || (hx < vr + m && hx + hw > vl - m);
136
+ };
137
+ // Default range for a number with no bounds: 0–1 keeps 0–1, a positive spans 0–3× (100 as a
138
+ // last resort), and a negative spans that much either side of zero, so it can still cross it
139
+ // (rotate: -45 → −135 to 135).
140
+ const defaultRange = (v: number): [number, number] => {
141
+ if (!Number.isFinite(v)) return [0, 1]; // NaN spanned NaN–NaN; every entry point now reads it as 0, as a verbose slider did
142
+ const x3 = Number((Math.abs(v) * 3).toPrecision(12)); // 2.3 × 3 is 6.8999…, which the grid trimmed to 6.8
143
+ if (v >= 0) return [0, v <= 1 ? 1 : x3 || 100];
144
+ const m = v >= -1 ? 1 : x3;
145
+ return [-m, m];
146
+ };
147
+
148
+ // An option is a string, a { value, label } object, or a bare primitive that is its own value.
149
+ const optValue = (o: Option) => (o == null ? undefined : typeof o === "object" ? o.value : o);
150
+ // An object's readable form: its JSON, or String() when it can't be stringified (a cycle).
151
+ const json = (v: unknown) => { try { return JSON.stringify(v); } catch { return String(v); } };
152
+ // A string option shows as written ("px", "sRGB"): it's the author's text, not a key. A { value }
153
+ // option with no label reads as its value's string; an object with neither, as its JSON.
154
+ const optLabel = (o: Option) => (o == null ? "" : typeof o === "string" ? o : typeof o === "object" ? o.label ?? (o.value !== undefined ? String(o.value) : json(o)) : String(o));
155
+
156
+ // The DOM factory, typed by tag like createElement (svgEl in heavy.ts is its SVG twin). A field it
157
+ // makes is a setting, not a form: autofill and the password managers leave it alone.
158
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string): Built<HTMLElementTagNameMap[K]> => {
159
+ const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html;
160
+ if (tag == "input" || tag == "textarea") n.setAttribute("autocomplete", "off"), n.setAttribute("data-1p-ignore", ""), n.setAttribute("data-lpignore", "true");
161
+ return n;
162
+ };
163
+ // Every <button> is type="button" — inside a host's <form> the default would submit — and
164
+ // text goes through textContent, never innerHTML: labels are host data.
165
+ const btn = (cls: string, html?: string) => { const b = el("button", cls, html); b.type = "button"; return b; };
166
+ const txt = <K extends keyof HTMLElementTagNameMap>(tag: K, cls: string, text: string) => { const n = tag === "button" ? btn(cls) : el(tag, cls); n.textContent = text; return n as Built<HTMLElementTagNameMap[K]>; };
167
+ // Stop a node's pointer events and clicks leaking to the page behind it.
168
+ const stopPointerLeak = (node: EventTarget) => ["pointerdown", "pointermove", "pointerup", "click"].forEach((t) => node.addEventListener(t, (e) => e.stopPropagation()));
169
+ // Run fn next frame and again once web fonts load — font swaps change the metrics controls measure.
170
+ const onReady = (fn: () => void) => { requestAnimationFrame(fn); if (document.fonts && document.fonts.ready) document.fonts.ready.then(fn); };
171
+ // Listen on global targets for a DOM-bound owner: the first event after the owner has left
172
+ // the document drops the listener set. "Left" means it was connected once, so an event
173
+ // between build and the host's append is skipped, not fatal. Returns the release fn.
174
+ const onLive = (owner: Element, targets: Array<[EventTarget, string]>, fn: (e?: Event) => void) => {
175
+ let mounted = owner.isConnected;
176
+ // A host appends right after building, before any event can arrive: note that mount next
177
+ // frame, so an unmount with no event in between still releases on the first event after.
178
+ if (!mounted) requestAnimationFrame(() => { if (owner.isConnected) mounted = true; });
179
+ const h = (e: Event) => { if (!owner.isConnected) { if (mounted || owner.closest("[data-tw-destroyed]")) off(); return; } mounted = true; fn(e); }; // destroy() marks the owner; release even if never mounted
180
+ const off = () => targets.forEach(([t, ev]) => t.removeEventListener(ev, h));
181
+ targets.forEach(([t, ev]) => t.addEventListener(ev, h));
182
+ return off;
183
+ };
184
+ // Ask every measured surface to re-measure next frame after a display:none → shown flip.
185
+ // Namespaced, not a real "resize": host pages listen to that.
186
+ const requestReflow = () => requestAnimationFrame(() => window.dispatchEvent(new Event("tw-reflow")));
187
+ // Toggle .is-hover while the pointer is over a node; onEnter runs on entry.
188
+ const wireHoverClass = (el: HTMLElement, onEnter: () => void) => {
189
+ el.addEventListener("pointerenter", () => { el.classList.add("is-hover"); onEnter(); });
190
+ el.addEventListener("pointerleave", () => el.classList.remove("is-hover"));
191
+ };
192
+ // ── Quiet mouse focus — :focus-visible always matches a focused text field, so a plain
193
+ // click drew the keyboard ring. One document-level modality note: a pointer press quiets
194
+ // the next focus, any key restores the ring. Installs once per page. ──
195
+ let pointerModality = false;
196
+ if (typeof document !== "undefined") {
197
+ document.addEventListener("pointerdown", () => { pointerModality = true; }, true);
198
+ document.addEventListener("keydown", () => { pointerModality = false; }, true);
199
+ }
200
+ const quietFocus = (input: HTMLElement) => {
201
+ input.addEventListener("focus", () => input.classList.toggle("tw-focus-quiet", pointerModality));
202
+ input.addEventListener("blur", () => input.classList.remove("tw-focus-quiet"));
203
+ };
204
+ /** Place a portaled popover under its trigger, flipping above when it won't fit, clamped into
205
+ * the viewport. width:"match" sizes it to the trigger; align:"end" lines up the right edges. */
206
+ export const placeBelow = (trigger: Element, pop: HTMLElement, { width, fallbackH = 300, gap = 6, align = "start", prefer = "below" }: { width?: number | "match"; fallbackH?: number; gap?: number; align?: "start" | "end" | "center"; prefer?: "above" | "below" } = {}) => {
207
+ const r = trigger.getBoundingClientRect();
208
+ if (width === "match") pop.style.width = r.width + "px";
209
+ const w = pop.offsetWidth || (width === "match" ? r.width : width || 0); // drawn width, which min-width may hold above a match
210
+ // A match held wider by min-width grows left from the trigger's right edge, so in a nested folder it spans the panel's row.
211
+ const end = align === "end" || (width === "match" && w > r.width + 0.5);
212
+ const x = end ? r.right - w : align === "center" ? r.left + r.width / 2 - w / 2 : r.left;
213
+ pop.style.left = Math.max(8, Math.min(x, window.innerWidth - w - 8)) + "px";
214
+ const h = pop.offsetHeight || fallbackH;
215
+ const below = window.innerHeight - r.bottom, above = r.top, need = h + 12;
216
+ // The preferred side if it fits, else the other if it fits, else whichever has more room.
217
+ const fitsBelow = below >= need, fitsAbove = above >= need;
218
+ const goAbove = prefer === "above" ? (fitsAbove || (!fitsBelow && above >= below)) : (!fitsBelow && (fitsAbove || above > below));
219
+ const top = goAbove ? r.top - h - gap : r.bottom + gap;
220
+ pop.style.top = clamp(top, gap, window.innerHeight - h - gap) + "px"; // clamp Y into the viewport
221
+ // For the CSS entrance: the side it opened on, and its height.
222
+ pop.dataset.side = goAbove ? "above" : "below"; pop.style.setProperty("--tw-pop-h", String(h));
223
+ };
224
+
225
+ // ── Theming — the panel runs on --tw-* custom properties, so a theme is a set of those vars, which applyThemeVars
226
+ // sets inline. A key that isn't a token is skipped out loud.
227
+ const resolveTheme = (theme: Theme | null | undefined): ThemeVars | null => {
228
+ if (!theme || typeof theme !== "object") return null;
229
+ const out: ThemeVars = {};
230
+ for (const [k, v] of Object.entries(theme)) {
231
+ if (v == null) continue; // unset, as an optional spread leaves a key
232
+ if (k.startsWith("--")) out[k] = String(v); else notice("theme", k);
233
+ }
234
+ return Object.keys(out).length ? out : null;
235
+ };
236
+ // Clear the previous application first, so a partial theme reverts what it no longer names.
237
+ const applyThemeVars = (node: ThemedEl | null | undefined, vars: ThemeVars | null | undefined) => {
238
+ if (!node) return;
239
+ if (node._twAppliedVars) for (const k of node._twAppliedVars) node.style.removeProperty(k);
240
+ node._twAppliedVars = vars ? Object.keys(vars) : null;
241
+ if (vars) for (const k in vars) node.style.setProperty(k, vars[k]);
242
+ };
243
+ // Carry a forced scheme onto a node portaled to <body>, where the cascade can't reach. Copy
244
+ // the WINNING scheme, not the nearest pin: pins resolve flat (light outranks dark; tweaks.css).
245
+ const carryScheme = (portal: Element, anchor: Element | null | undefined) => {
246
+ const s = anchor?.closest('[data-tw-scheme="light"]') ? "light" : anchor?.closest('[data-tw-scheme="dark"]') ? "dark" : null;
247
+ if (s) portal.setAttribute("data-tw-scheme", s); else portal.removeAttribute("data-tw-scheme");
248
+ };
249
+ // …and the anchor panel's live theme (read at carry time — setTheme() swaps the object).
250
+ const carrySkin = (portal: HTMLElement, anchor: Element | null | undefined) => { applyThemeVars(portal, anchor?.closest<PanelEl>(".tw-panel")?._twTheme); carryScheme(portal, anchor); };
251
+
252
+ // ── Popover — the portal-to-<body> shell behind every transient surface. Opens under its
253
+ // trigger at its width (unless opts.width), carries the host theme, closes on outside-press / Esc / scroll-out. Globally
254
+ // single-open. onOpen runs once it's placed at real size; onReflow on resize while open. ──
255
+ let activePopoverClose: null | (() => void) = null, activePopoverTrigger: HTMLElement | null = null;
256
+ // Closes the open popover when its trigger lives inside `owner`.
257
+ const closeActivePopover = (owner: Element) => { if (activePopoverClose && owner.contains(activePopoverTrigger)) activePopoverClose(); };
258
+ function popover(root: HTMLElement, trigger: HTMLButtonElement, pop: HTMLElement, opts: { width?: number | "match"; fallbackH?: number; align?: "start" | "end"; movable?: boolean; onOpen?: () => void; onReflow?: () => void; initialFocus?: () => HTMLElement | null | undefined } = {}): Popover {
259
+ let open = false, schemeObs: MutationObserver | null = null, removeTimer: ReturnType<typeof setTimeout> | undefined, byPointer = false, sizeObs: ResizeObserver | null = null;
260
+ // Focus goes back to the row as the pane was opened: quietly after a click, even when an Escape closes it.
261
+ const refocus = () => trigger.focus({ focusVisible: !byPointer });
262
+ pop.classList.add("tw-portal"); // portal-wide CSS rules key off this
263
+ // A closed pop stays in the DOM at opacity 0; inert keeps it out of the tab order and a11y tree.
264
+ pop.inert = true;
265
+ // Relay pointerdowns to the host panel's edit hook — on <body>, its own listener can't see them.
266
+ stopPointerLeak(pop); // on <body>, outside the panel's own pointer-stop
267
+ // A scroll carries the pop along at its offset from the trigger (follow) instead of
268
+ // re-placing it; once the row has left the viewport the pop closes.
269
+ let dx = 0, dy = 0;
270
+ // A movable pane drags by its edge (heavy.ts dragPane, bound on the first open); `st` is the state the two share.
271
+ const st = { open: false, moved: false }; let dragBound = false;
272
+ const keepInView = () => { pop.style.left = clamp(parseFloat(pop.style.left), 8, window.innerWidth - pop.offsetWidth - 8) + "px"; pop.style.top = clamp(parseFloat(pop.style.top), 8, window.innerHeight - pop.offsetHeight - 8) + "px"; };
273
+ const place = () => {
274
+ if (st.moved) keepInView(); else placeBelow(trigger, pop, { width: opts.width ?? "match", fallbackH: opts.fallbackH, align: opts.align });
275
+ const r = trigger.getBoundingClientRect(); dx = parseFloat(pop.style.left) - r.left; dy = parseFloat(pop.style.top) - r.top;
276
+ };
277
+ // A detached trigger has a zero rect: if the host has unmounted the panel, close instead.
278
+ const reflow = () => { if (open) { if (!root.isConnected) return close(); place(); opts.onReflow && opts.onReflow(); } };
279
+ const follow = () => {
280
+ if (!open) return; if (!root.isConnected) return close();
281
+ const r = trigger.getBoundingClientRect();
282
+ const sr = trigger.closest(".tw-panel")?.getBoundingClientRect(); // a floating panel scrolls, and clips its rows (an inline one holds them all)
283
+ if (!(r.width || r.height) || r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth || (sr?.height && (r.bottom < sr.top || r.top > sr.bottom))) return close(); // hidden, or scrolled out
284
+ pop.style.left = r.left + dx + "px"; pop.style.top = r.top + dy + "px";
285
+ };
286
+ const onOutside = (e: PointerEvent) => { if (!root.contains(e.target as Node) && !pop.contains(e.target as Node)) close(); };
287
+ // Escape closes and stops propagation, so a host dialog doesn't close too. A field reverting an edit stops it first.
288
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && open && !e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); close(); refocus(); } };
289
+ // Focus leaving both the pop and its trigger closes. A blur with no relatedTarget is
290
+ // ambiguous (a press on a drag surface inside the pop blurs to <body> too), so the modality
291
+ // note decides. A Tab that lands outside the panel comes back to the trigger a tick later.
292
+ const onFocusOut = (e: FocusEvent) => {
293
+ if (!open) return; const t = e.relatedTarget as Node | null;
294
+ if (!t && !document.hasFocus()) return; // the window lost focus (a file dialog, another app): the pane waits for it
295
+ if (t ? root.contains(t) || pop.contains(t) : pointerModality) return;
296
+ close();
297
+ if (t && !trigger.closest(".tw-panel")?.contains(t)) setTimeout(() => { if (!open && document.activeElement === t) trigger.focus(); }, 0);
298
+ };
299
+ root.addEventListener("focusout", onFocusOut); pop.addEventListener("focusout", onFocusOut);
300
+ const firstFocusable = () => pop.querySelector<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])');
301
+ // Re-carry the host scheme + theme while open: the cascade can't reach <body>, so a mid-open
302
+ // flip would strand a mismatched popover. The observer ignores its own write on `pop`.
303
+ const recarry = () => carrySkin(pop, root);
304
+ const onScheme = (recs: MutationRecord[]) => { if (!recs.every((r) => r.target === pop)) recarry(); };
305
+ const openPop = () => {
306
+ if (activePopoverClose && activePopoverClose !== close) activePopoverClose(); // close any other open popover first
307
+ activePopoverClose = close; activePopoverTrigger = trigger;
308
+ open = st.open = true; root.classList.add("is-open"); trigger.setAttribute("aria-expanded", "true");
309
+ // Reopened mid-fade, it's still on <body>; re-appending would kill the transition, so it reverses in place.
310
+ clearTimeout(removeTimer); const fading = pop.parentNode === document.body; st.moved = false; byPointer = pointerModality;
311
+ pop.inert = false; if (!fading) document.body.appendChild(pop);
312
+ if (!dragBound && opts.movable !== false) { dragBound = true; import("./heavy.js").then((m) => m.dragPane(pop, st, place), () => {}); }
313
+ carrySkin(pop, root);
314
+ window.addEventListener("tw-retheme", recarry); // setTheme() while open
315
+ if (typeof MutationObserver === "function") { schemeObs = new MutationObserver(onScheme); schemeObs.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ["data-tw-scheme"] }); } // host scheme flip while open
316
+ // Place with transitions off: the recorded side and height set the closed state the entrance starts from.
317
+ if (fading) place(); else { pop.style.transition = "none"; place(); void pop.offsetWidth; pop.style.transition = ""; }
318
+ // A pane that changes size while open (a mode switch, a disclosed picker) stays on its row and inside the window.
319
+ if (typeof ResizeObserver === "function") { sizeObs = new ResizeObserver(() => { if (open) place(); }); sizeObs.observe(pop); }
320
+ requestAnimationFrame(() => {
321
+ if (!open) return; // a same-tick close already ran
322
+ pop.classList.add("is-open"); opts.onOpen && opts.onOpen(); place(); // render at real size, then re-place
323
+ // Focus moves into the pop, or Tab walks past the portaled node at the end of <body>.
324
+ // initialFocus wins over the first focusable; a pointer-opened pop takes focus quietly.
325
+ if (!pop.contains(document.activeElement)) { const f = (opts.initialFocus && opts.initialFocus()) || firstFocusable(); if (f) f.focus({ focusVisible: !pointerModality }); }
326
+ });
327
+ // Unmount watchdog: a host removing the panel while open (an SPA route change) would
328
+ // otherwise strand the portaled pop on screen.
329
+ requestAnimationFrame(function watch() { if (!open) return; if (!root.isConnected) return close(); requestAnimationFrame(watch); });
330
+ // Capture phase: the panel's own stopPointerLeak would swallow the event before it bubbles.
331
+ setTimeout(() => { if (open) document.addEventListener("pointerdown", onOutside, true); }, 0); // skip the opening click
332
+ document.addEventListener("keydown", onKey); // Esc closes from anywhere while open
333
+ window.addEventListener("scroll", follow, true); window.addEventListener("resize", reflow); // capture: inner scrollers move the row too
334
+ };
335
+ const close = () => {
336
+ if (activePopoverClose === close) { activePopoverClose = null; activePopoverTrigger = null; }
337
+ // Focus stranded inside the pop returns to the trigger before the node is removed.
338
+ if (pop.contains(document.activeElement)) refocus();
339
+ open = st.open = false; root.classList.remove("is-open"); pop.classList.remove("is-open"); pop.inert = true; trigger.setAttribute("aria-expanded", "false");
340
+ document.removeEventListener("pointerdown", onOutside, true); document.removeEventListener("keydown", onKey);
341
+ window.removeEventListener("scroll", follow, true); window.removeEventListener("resize", reflow);
342
+ window.removeEventListener("tw-retheme", recarry); if (schemeObs) { schemeObs.disconnect(); schemeObs = null; } sizeObs?.disconnect(); sizeObs = null;
343
+ removeTimer = setTimeout(() => { if (!open) pop.remove(); }, 200); // remove the portaled node once it's faded out
344
+ };
345
+ trigger.addEventListener("click", () => { if (trigger.disabled) return; open ? close() : openPop(); }); // a disabled trigger stays inert to a synthetic click
346
+ return { open: openPop, close, isOpen: () => open, reflow };
347
+ }
348
+
349
+ // Fuzzy match for the filter: `q` is a substring of `text`, or within a small edit distance
350
+ // of a window of it ("blut" finds "blur"); the DP's zeroed first row lets a match start anywhere.
351
+
352
+ const REDUCE_MOTION: { matches: boolean } = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : { matches: false };
353
+ // ── Motion tokens — EASE_SPRING is the JS literal of the CSS --tw-ease-spring: WAAPI and
354
+ // nodes outside a .tw-panel can't read the custom property. ──
355
+ const EASE_SPRING = "cubic-bezier(0.22, 1, 0.36, 1)";
356
+ const EASE_GLIDE = "cubic-bezier(0.34, 1.2, 0.64, 1)"; // press glide with a light overshoot
357
+ // Liquid pill stretch (segmented + tabs): a one-shot scaleX overshoot, origin pinned to the
358
+ // trailing edge so it reads directional. dir>0 = moving right.
359
+ const stretchPill = (pill: HTMLElement, dir: number) => {
360
+ if (REDUCE_MOTION.matches || typeof pill.animate !== "function") return;
361
+ pill.style.transformOrigin = dir > 0 ? "left center" : "right center";
362
+ pill.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(1.18)" }, { transform: "scaleX(1)" }], { duration: 300, easing: EASE_SPRING });
363
+ };
364
+ // Slide a pill to the active child of `container`. The box reads come before the writes so a
365
+ // measure can't force an extra layout; the stretch runs on a real move.
366
+ const measurePill = (container: Element, pill: HTMLElement, animate?: boolean) => {
367
+ const a = container.querySelector<HTMLElement>('[data-active="true"]'); if (!a) return;
368
+ const left = a.offsetLeft, width = a.offsetWidth, prev = parseFloat(pill.style.left);
369
+ pill.style.left = left + "px"; pill.style.width = width + "px";
370
+ if (animate && Number.isFinite(prev) && prev !== left) stretchPill(pill, left > prev ? 1 : -1);
371
+ };
372
+ // Slider rubber-band (slider + interval): dragged past an end by more than a 32px dead zone, the
373
+ // track widens toward the cursor, up to 8px on a sqrt falloff over the next 200px. rect is the wrap's box at press.
374
+ const RUBBER_DEAD = 32, RUBBER_RANGE = 200, RUBBER_MAX = 8;
375
+ const rubberBand = (track: HTMLElement, rect: DOMRect, clientX: number) => {
376
+ const over = clientX < rect.left ? clientX - rect.left : clientX > rect.right ? clientX - rect.right : 0;
377
+ const s = Math.sign(over) * RUBBER_MAX * Math.sqrt(Math.min(Math.max(0, Math.abs(over) - RUBBER_DEAD) / RUBBER_RANGE, 1));
378
+ track.style.width = s ? `calc(100% + ${Math.abs(s)}px)` : "";
379
+ track.style.transform = s < 0 ? `translateX(${s}px)` : "";
380
+ };
381
+ // Release: spring the track home, then drop the transition so the next drag tracks 1:1.
382
+ const rubberRelease = (track: HTMLElement) => {
383
+ track.style.transition = `width 0.35s ${EASE_SPRING}, transform 0.35s ${EASE_SPRING}`;
384
+ track.style.width = ""; track.style.transform = "";
385
+ setTimeout(() => { track.style.transition = ""; }, 360);
386
+ };
387
+
388
+ // Disable a row: `inert` goes on the children, not the row — an inert element hit-tests like
389
+ // pointer-events:none, so an inert row would never show its not-allowed cursor.
390
+ const setDisabled = (node: HTMLElement, d: boolean) => {
391
+ node.classList.toggle("is-disabled", d); for (const c of node.children) if (!c.classList.contains("tw-portal")) (c as HTMLElement).inert = d; // a closed popover child keeps its own inert
392
+ if (d) closeActivePopover(node); // an open pane is portaled out of reach of inert: a control disabled mid-edit closes it
393
+ };
394
+ // Collapse a section: the fold class, aria-expanded, and `inert` on the body so hidden controls leave the tab order.
395
+ const setCollapsed = (root: Element, toggle: Element, body: HTMLElement, c: boolean) => { root.classList.toggle("is-collapsed", c); toggle.setAttribute("aria-expanded", String(!c)); body.inert = c; };
396
+ // …and animate the fold over only the visible height: content past the height cap or the window would otherwise stall,
397
+ // then snap. Without the Animations API, or with reduced motion, the CSS transition runs; `done` runs once settled.
398
+ type Folding = HTMLElement & { _twFold?: Animation | null };
399
+ const foldBody = (root: Element, toggle: Element, body: Folding, c: boolean, done?: () => void) => {
400
+ if (REDUCE_MOTION.matches || typeof body.animate !== "function") { setCollapsed(root, toggle, body, c); if (done) setTimeout(done, 370); return; }
401
+ // Everything read before the class flips, so a collapse forces no layout after it.
402
+ const r = body.getBoundingClientRect(), from = r.height; // mid-fold, the height it's drawn at
403
+ const panel = root.closest<HTMLElement>(".tw-panel"), cs = panel && getComputedStyle(panel), maxH = cs ? parseFloat(cs.maxHeight) : NaN; // border-box, so the cap includes the padding
404
+ const capBottom = cs && panel.dataset.mode === "floating" && maxH >= 0 ? panel.getBoundingClientRect().top + maxH - parseFloat(cs.paddingBottom) - parseFloat(cs.borderBottomWidth) : Infinity;
405
+ const room = Math.max(0, Math.min(window.innerHeight, capBottom) - r.top), easing = getComputedStyle(body).getPropertyValue("--tw-ease-fold").trim();
406
+ body._twFold?.cancel(); body._twFold = null;
407
+ body.style.transition = "none"; body.style.gridTemplateRows = "";
408
+ setCollapsed(root, toggle, body, c);
409
+ let to = 0; // collapsed is always 0
410
+ if (!c) {
411
+ // Finish the folder's padding transition first: it's hidden under the fold, and the end height must include it.
412
+ for (const a of body.firstElementChild?.getAnimations?.() ?? []) if ((a as CSSTransition).transitionProperty !== "opacity") a.finish();
413
+ to = body.getBoundingClientRect().height;
414
+ }
415
+ const f = Math.min(from, room), t = Math.min(to, room);
416
+ // fill: forwards holds the last frame until the 1fr comes off; otherwise one frame would draw the whole body.
417
+ const finish = () => { body.style.gridTemplateRows = ""; void body.offsetHeight; body._twFold?.cancel(); body._twFold = null; body.style.transition = ""; done?.(); };
418
+ if (Math.abs(f - t) < 1) return finish();
419
+ body.style.gridTemplateRows = "1fr"; // the rows stay laid out under the fold, clipped from the bottom
420
+ const a = body._twFold = body.animate([{ height: f + "px" }, { height: t + "px" }], { duration: 350, easing, fill: "forwards" });
421
+ a.onfinish = () => { if (body._twFold === a) finish(); };
422
+ };
423
+ // The button whose (stringified) value is active — or, when none matches, the first: the
424
+ // group's roving tab stop. Only an empty group gives −1.
425
+ const activeIndex = (btns: RadioBtn[], value: unknown) => { const i = btns.findIndex((b) => b.dataset.value === String(value)); return i < 0 && btns.length ? 0 : i; };
426
+ // Reflect a single-select value onto a radio group: data-active, aria-checked, and a roving
427
+ // tabindex. Paint follows the real match; the tab stop follows activeIndex's fallback.
428
+ const setRadioActive = (btns: RadioBtn[], value: unknown) => { const stop = activeIndex(btns, value); btns.forEach((b, k) => { const on = b.dataset.value === String(value); b.dataset.active = String(on); b.setAttribute("aria-checked", String(on)); b.tabIndex = k === stop ? 0 : -1; }); };
429
+ // A single-select radio button. _twVal carries the option's real value — dataset stringifies,
430
+ // so a keyboard pick off dataset.value turned a numeric option into a string.
431
+ const radioButton = (cls: string, o: Option, onPick: (v: string | number | undefined) => void) => { const b: RadioBtn = txt("button", cls, optLabel(o)); b.setAttribute("role", "radio"); b._twVal = optValue(o); b.dataset.value = String(b._twVal); b.addEventListener("click", () => onPick(b._twVal)); return b; };
432
+ // Arrow-key navigation → the next index, or −1 when the key isn't the group's. cols > 0 jumps
433
+ // ↑/↓ by a row (the radio grid); 0 treats ↑/↓ as ←/→; < 0 leaves ↑/↓ to the page (the tab bar).
434
+ const navIndex = (key: string, i: number, n: number, cols = 0) => {
435
+ switch (key) {
436
+ case "ArrowRight": return (i + 1) % n;
437
+ case "ArrowLeft": return (i - 1 + n) % n;
438
+ case "ArrowDown": return cols < 0 ? -1 : cols ? (i + cols < n ? i + cols : i) : (i + 1) % n;
439
+ case "ArrowUp": return cols < 0 ? -1 : cols ? (i - cols >= 0 ? i - cols : i) : (i - 1 + n) % n;
440
+ case "Home": return 0;
441
+ case "End": return n - 1;
442
+ }
443
+ return -1;
444
+ };
445
+ // ── Segmented control — a single-select pill row (the boolean toggle, the spring's mode switch). ──
446
+ // `onIs`: the boolean toggle, a two-segment row that is one switch to a screen reader (role="switch", aria-checked, one tab
447
+ // stop, Space or Enter flips it) rather than "radio button, On, 2 of 2"; its Off/On segments stay as the pointer's targets.
448
+ function createSegmented(options: Option[], value: unknown, onChange: OnChange, ariaLabel?: string, onIs?: unknown) {
449
+ const sw = onIs !== undefined, seg = el("div", "tw-seg"); seg.setAttribute("role", sw ? "switch" : "radiogroup");
450
+ if (ariaLabel) seg.setAttribute("aria-label", ariaLabel);
451
+ if (sw) seg.tabIndex = 0;
452
+ const pill = el("div", "tw-seg-pill");
453
+ seg.append(pill);
454
+ const btns = options.map((o) => { const b = radioButton("tw-seg-btn", o, (v) => set(v)); if (sw) { b.setAttribute("aria-hidden", "true"); b.addEventListener("focus", () => seg.focus()); } seg.append(b); return b; }); // lazy `set` — it's declared below; a clicked segment hands focus to the switch, never parks it hidden
455
+ const measure = (animate?: boolean) => measurePill(seg, pill, animate);
456
+ const reflect = () => { setRadioActive(btns, value); if (sw) { seg.setAttribute("aria-checked", String(value === onIs)); btns.forEach((b) => (b.tabIndex = -1)); } measure(true); };
457
+ const set = (v: unknown, fire = true) => { if (v != null && !btns.some((b) => b._twVal === v)) return; value = v; reflect(); if (fire) onChange(v); }; // null clears; a value matching no segment is ignored
458
+ seg.addEventListener("keydown", (e) => {
459
+ const i = activeIndex(btns, value); if (i < 0) return;
460
+ if (sw && (e.key === " " || e.key === "Enter")) { e.preventDefault(); set(btns[1 - i]._twVal); return; }
461
+ // A switch's arrows point at a side, Off or On, and stop there: wrapping, → on On turned it off.
462
+ const j = sw ? ({ ArrowRight: 1, ArrowUp: 1, End: 1, ArrowLeft: 0, ArrowDown: 0, Home: 0 } as Record<string, number>)[e.key] ?? -1 : navIndex(e.key, i, btns.length); if (j < 0) return;
463
+ e.preventDefault(); set(btns[j]._twVal); (sw ? seg : btns[j]).focus(); // _twVal: the real, possibly non-string value
464
+ });
465
+ reflect();
466
+ onReady(() => { measure(); seg.classList.add("is-ready"); }); // once laid out, again when fonts land
467
+ // A tab page revealing this control re-measures the pill, built at 0×0 while display:none.
468
+ onLive(seg, [[window, "tw-reflow"]], () => measure());
469
+ return { el: seg, set: (v: unknown) => set(v, false), get: () => value };
470
+ }
471
+
472
+ // One stroke-icon shell for every inline SVG. It lives here, not in icons.ts, because a lazy
473
+ // control imports it: anything both core and a lazy chunk import must sit in the shared chunk,
474
+ // or esbuild hoists the module into a third chunk every basic panel has to fetch.
475
+ const icon = (body: string, cls = "", width = 2, box = 24) => `<svg${cls ? ` class="${cls}"` : ""} viewBox="0 0 ${box} ${box}" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
476
+
477
+ // The handle a display/action control returns: no value, so build skips entry/reset/persist
478
+ // wiring. `destroy` lets panel.destroy() stop a control's loop even if the panel never connected.
479
+ const blade = (el: HTMLElement, destroy?: () => void): Control => ({ el, set: () => {}, get: () => undefined, destroy });
480
+
481
+ // Own-key lookup for objects used as maps: "toString" must miss, not hit Object.prototype.
482
+ const hasOwn = <T extends object>(o: T, k: PropertyKey): k is keyof T => Object.prototype.hasOwnProperty.call(o, k);
483
+ // A key by one of these names would write through to Object.prototype on the params bag.
484
+ const isReservedKey = (k: string) => k === "__proto__" || k === "constructor" || k === "prototype";
485
+ // Identity for primitives, structural for the object-valued controls (a fresh object each
486
+ // get()). Gates notify(): a same-value echo from an on() listener recursed to stack exhaustion.
487
+ const valueChanged = (a: unknown, b: unknown) => a !== b && !(isObj(a) && isObj(b) && JSON.stringify(a) === JSON.stringify(b));
488
+ // Reset: the form the control OPENED on first (it covers a value set() can't take as-is),
489
+ // then the authored value on top, so whatever the control CAN take lands exactly.
490
+ const restoreDefault = (ctrl: Pick<Control, "set">, raw: unknown, def: unknown) => { ctrl.set(def); ctrl.set(raw); }; // a control, or the panel's entry for one
491
+ const SPRING_KEYS = ["stiffness", "damping", "mass", "visualDuration", "bounce"]; // read by the spring and motion controls, and their markup
492
+ // ── Control registry — type → constructor. Core controls register on load; a lazy one when
493
+ // its module is imported, so the loaded set drives what can be built. ──
494
+ const REGISTRY: Record<string, ControlCtor<any, any>> = {}; // heterogeneous by design — see ControlCtor
495
+ export const registerControl = (type: string, ctor: ControlCtor<any, any>) => { REGISTRY[type] = ctor; };
496
+ export const getControl = <C = Control, M = Meta>(type: string): ControlCtor<M, C> | undefined => REGISTRY[type];
497
+ export const controlTypes = () => Object.keys(REGISTRY);
498
+ // The markup chunk's enhance(), registered when markup.ts loads (enhance.ts fetches it, or single.ts imports it).
499
+ let MARKUP: ((root: Document | Element, kit: any) => Promise<void>) | null = null; // any: the kit's type lives in markup.ts, which imports this
500
+ export const setMarkup = (fn: typeof MARKUP) => { MARKUP = fn; };
501
+ export const getMarkup = <K>() => MARKUP as ((root: Document | Element, kit: K) => Promise<void>) | null;
502
+
503
+ // What a click or a run of keys did, in words, for a screen reader: one visually hidden status region for the page,
504
+ // where the toast used to speak. It's cleared and written a beat later, so the same words twice are read twice.
505
+ let srEl: HTMLDivElement | null = null, srT = 0;
506
+ const announce = (msg: string) => {
507
+ if (!srEl) { srEl = el("div", "tw-sr"); srEl.setAttribute("role", "status"); }
508
+ if (!srEl.isConnected) document.body.appendChild(srEl); // re-home after a host replaced <body>'s content
509
+ srEl.textContent = ""; clearTimeout(srT); srT = setTimeout(() => { srEl!.textContent = msg; }, 100);
510
+ };
511
+
512
+ export {
513
+ titleCase, clamp, isObj, isColorStr, stepPrecision, gridEnds, roundToStep, inferStep, defaultRange,
514
+ normalizeRange, notice, sayNow, rangeStep, overlapsText,
515
+ optValue, optLabel, json, el, btn, txt, stopPointerLeak, onReady, onLive, requestReflow,
516
+ wireHoverClass, popover, closeActivePopover,
517
+ resolveTheme, applyThemeVars, carryScheme, carrySkin, setCollapsed, foldBody, setDisabled, activeIndex, setRadioActive, radioButton, navIndex, createSegmented,
518
+ blade, quietFocus, measurePill, rubberBand, rubberRelease, REDUCE_MOTION, EASE_SPRING, EASE_GLIDE, icon, announce, hasOwn, isReservedKey, valueChanged, restoreDefault, SPRING_KEYS,
519
+ };
520
+ export type { OnChange, ThemeVars, PanelEl, RadioBtn, Built, NumSpec, NumField, Popover, ControlCtor };
521
+ // Re-exported for the chunking: core and the gradient control both import easing.ts, and a
522
+ // module only those two reach would become a chunk of its own (test/chunks.test.mjs holds it
523
+ // to three). Core exports from easing.ts directly so the .d.ts prune skips shared.ts internals.
524
+ export { gradientCss, gradientStops } from "./easing.js";
525
+
@@ -0,0 +1,22 @@
1
+ /* Single-file build entry. Statically imports every control so each self-registers
2
+ * into the shared registry on load; with the esbuild define TW_SPLIT=false, core's
3
+ * dynamic-import map is empty, so esbuild inlines the whole kit into one self-contained,
4
+ * synchronous file — a drop-in that needs no bundler. The code-split build uses core.ts
5
+ * directly (TW_SPLIT=true) and loads these controls on demand instead. */
6
+ import "./markup.js"; // registers enhance()'s work, so enhance() stays synchronous here
7
+ import "./heavy.js"; // registers the Number control
8
+ import "./controls/color.js";
9
+ import "./controls/gradient.js";
10
+ import "./controls/shadow.js";
11
+ import "./controls/sides.js";
12
+ import "./controls/interval.js";
13
+ import "./controls/tabs.js";
14
+ import "./controls/image.js";
15
+ import "./controls/monitor.js";
16
+ import "./controls/spring.js";
17
+ import "./controls/bezier.js";
18
+ import "./controls/motion.js";
19
+ import "./controls/point.js";
20
+ import "./controls/plot.js";
21
+
22
+ export * from "./core.js";