tweakit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -1,29 +1,59 @@
1
1
  /* Tweaks — shared building blocks: DOM + math helpers, theming, the popover shell and
2
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";
3
+ import type { Control, Option, Panel, Theme, Strings } from "./types.js";
4
4
  import type { Meta } from "./schema.js";
5
5
 
6
6
  // ── Internal types ──
7
7
  /** A control's change callback. */
8
8
  type OnChange = (value: unknown) => void;
9
- /** The resolved `--tw-*` custom-property map a theme turns into (resolveTheme). */
9
+ /** The resolved `--twk-*` custom-property map a theme turns into (resolveTheme). */
10
10
  type ThemeVars = Record<string, string>;
11
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> }
12
+ interface PanelEl extends HTMLElement {
13
+ _twTheme?: ThemeVars | null;
14
+ ready?: Promise<Panel>;
15
+ }
13
16
  /** A node applyThemeVars has written to; remembers which properties, to clear them next time. */
14
- interface ThemedEl extends HTMLElement { _twAppliedVars?: string[] | null }
17
+ interface ThemedEl extends HTMLElement {
18
+ _twAppliedVars?: string[] | null;
19
+ }
15
20
  /** A single-select radio button carrying its option's real value (dataset stringifies). */
16
- interface RadioBtn extends HTMLButtonElement { _twVal?: string | number }
21
+ interface RadioBtn extends HTMLButtonElement {
22
+ _twVal?: string | number;
23
+ }
17
24
  /** setAttribute() / style.setProperty() taking a number as well as a string (the DOM
18
25
  * 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 } }
26
+ interface Stringifying {
27
+ setAttribute(name: string, value: string | number): void;
28
+ style: { setProperty(name: string, value: string | number | null, priority?: string): void };
29
+ }
20
30
  type Built<E extends Element> = E & Stringifying;
21
31
  /** 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
32
+ // unit: muted suffix, also in the accessible name
33
+ interface NumSpec {
34
+ label: string;
35
+ value?: unknown;
36
+ min?: unknown;
37
+ max?: unknown;
38
+ step?: unknown;
39
+ soft?: boolean;
40
+ row?: boolean;
41
+ unit?: string;
42
+ }
23
43
  /** 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] }
44
+ interface NumField {
45
+ el: HTMLDivElement;
46
+ set(v: number | string): void;
47
+ get(): number;
48
+ range: [number, number];
49
+ }
25
50
  /** The popover shell's handle (popover). */
26
- interface Popover { open(): void; close(): void; isOpen(): boolean; reflow(): void }
51
+ interface Popover {
52
+ open(): void;
53
+ close(): void;
54
+ isOpen(): boolean;
55
+ reflow(): void;
56
+ }
27
57
  /** A control constructor: normalized meta in, handle out. The registry holds every one under
28
58
  * the loose form; a lookup names the shape it expects — `getControl<TabsControl>("tabs")`. */
29
59
  type ControlCtor<M = Meta, C = Control> = (meta: M, onChange?: OnChange) => C;
@@ -33,12 +63,21 @@ type ControlCtor<M = Meta, C = Control> = (meta: M, onChange?: OnChange) => C;
33
63
  // A key opening on one lowercase letter and capitals, or on an Apple OS, is a name written as meant ("sRGB", "iOS",
34
64
  // "macOS", not "S RGB"), kept whole and the rest split after it ("sRGBGamut" → "sRGB Gamut"); a word ending in an
35
65
  // 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); };
66
+ const humanize = (s: string) =>
67
+ s
68
+ .replace(/([a-z])(?=[A-Z])|([A-Z\d])(?=[A-Z][a-z])/g, "$1$2 ")
69
+ .replace(/^./, (c) => c.toUpperCase())
70
+ .trim();
71
+ const titleCase = (s: string) => {
72
+ const m = /^(?:[a-z][A-Z]{2,}?|(?:mac|tv|watch|iPad|vision)OS)(?=[A-Z][a-z]|[^A-Za-z]|$)/.exec(s);
73
+ return m ? `${m[0]} ${humanize(s.slice(m[0].length))}`.trim() : humanize(s);
74
+ };
38
75
  const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
39
76
  const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === "object";
40
77
  // 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());
78
+ const isColorStr = (v: unknown) =>
79
+ typeof v === "string" &&
80
+ /^(#([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
81
  // toFixed() throws past 100 digits, and a readout past 20 decimals is noise.
43
82
  const MAX_FIXED = 20;
44
83
  const stepPrecision = (step: number) => {
@@ -53,64 +92,112 @@ const stepPrecision = (step: number) => {
53
92
  // finer min put every grid point half a decimal off, and each re-round walked the value up.
54
93
  const roundToStep = (v: number, min: number, step: number) => {
55
94
  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
95
+ const p = stepPrecision(step),
96
+ a = Number((+min).toFixed(p));
97
+ // a huge value over a tiny step overflows; keep the raw
98
+ const n = Math.round((v - a) / step);
99
+ if (!Number.isFinite(n)) return v;
58
100
  return Number((a + n * step).toFixed(p));
59
101
  };
60
102
  // The reachable ends of a [min, max] step grid, so a clamp never lands off-grid; a range too
61
103
  // narrow to hold a grid point pins to min. ±Infinity = open.
62
104
  const gridEnds = (min: number, max: number, step: number, anchor = min): [number, number] => {
63
105
  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.
106
+ let lo = Number.isFinite(min) ? r(min) : -Infinity,
107
+ hi = Number.isFinite(max) ? r(max) : Infinity;
108
+ // 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
109
+ // 0.31.
66
110
  const eps = step * 1e-6;
67
111
  if (lo < min - eps) lo = r(lo + step);
68
112
  if (hi > max + eps) hi = r(hi - step);
69
113
  return lo > hi ? [min, min] : [lo, hi];
70
114
  };
71
115
  // 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
116
+ // grid from min: a coarser grid would snap the default off what was written (37 over 0–110 to 40). A value finer still
73
117
  // (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,
118
+ // 10 from -135 (…, -5, 5, …), which steps over 0. roundToStep anchors the grid at min rounded to the step's decimals,
75
119
  // 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
120
  // step within reach fits it, else the grid trims it as before.
77
121
  const inferStep = (min: number, max: number, ...vals: number[]) => {
78
122
  const range = max - min;
79
123
  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;
124
+ // 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]
125
+ // was 0–0.
126
+ const base =
127
+ range > 0 && range < 1e-4
128
+ ? 10 ** Math.floor(Math.log10(range)) / 100
129
+ : range <= 1
130
+ ? 0.01
131
+ : range <= 10
132
+ ? 0.1
133
+ : range <= 100
134
+ ? 1
135
+ : 10;
82
136
  const on = (n: number) => !Number.isFinite(n) || Math.abs(n - Math.round(n)) <= 1e-6;
83
137
  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; };
138
+ const finest = (vs: number[]) => {
139
+ let step = base;
140
+ for (let i = 0; i < 2 && !fits(step, vs); i++) step /= 10;
141
+ return step;
142
+ };
85
143
  const withMax = finest([...vals, max]);
86
144
  return fits(withMax, [...vals, max]) ? withMax : finest(vals);
87
145
  };
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.
146
+ // The range a value implies when no bound is given, widened by each value (defaultRange), and its step. A range
147
+ // mirrored about zero steps like its positive half (-0.5 by 0.01, like 0.5), not by its doubled span, and its ends keep
148
+ // 0 on the grid.
90
149
  const derivedRange = (...vals: number[]) => {
91
150
  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) };
151
+ const min = Math.min(...rs.map((r) => r[0])),
152
+ max = Math.max(...rs.map((r) => r[1]));
153
+ return {
154
+ min,
155
+ max,
156
+ step: min < 0 && min === -max ? inferStep(0, max, max, ...vals.map(Math.abs)) : inferStep(min, max, ...vals),
157
+ };
94
158
  };
95
159
  // A console notice for a mistake. Its words live in the lazy heavy chunk (heavy.ts NOTICES), so a panel that makes none
96
160
  // never downloads them; it lands a moment later, which a console doesn't mind.
97
161
  // Once the heavy chunk is in (always on the single-file build), a notice prints at once, so code that reads the console
98
162
  // right after the set() that caused it sees it; before, it waits for the chunk.
99
163
  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), () => {}); };
164
+ const sayNow = (f: typeof says) => {
165
+ says = f;
166
+ };
167
+ const notice = (kind: string, ...args: unknown[]) => {
168
+ if (says) says(kind, args);
169
+ else
170
+ import("./heavy.js").then(
171
+ (m) => m.notice(kind, args),
172
+ () => {},
173
+ );
174
+ };
102
175
  // 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.
176
+ // 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
177
+ // NaN span. Every range source funnels here.
105
178
  const normalizeRange = (rawMin: unknown, rawMax: unknown, rawStep: unknown, vals: number[] = [], label?: string) => {
106
179
  const given = (n: unknown) => n != null && n !== "" && Number.isFinite(+n!);
107
180
  vals = vals.filter(Number.isFinite);
108
181
  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; }
182
+ let min = given(rawMin) ? +rawMin! : d.min,
183
+ max = given(rawMax) ? +rawMax! : d.max,
184
+ step = +rawStep!;
185
+ // One bound written at or past the values' own other end: that end derives from the written bound, as a bare number
186
+ // its 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
187
+ // 15–20 or 1–1.
188
+ if (given(rawMin) !== given(rawMax) && !(max > min)) {
189
+ const up = given(rawMin),
190
+ b = up ? min : max,
191
+ r = derivedRange(b);
192
+ if (up) max = r.max > b ? r.max : b + r.max - r.min;
193
+ else min = r.min < b ? r.min : b - r.max + r.min;
194
+ }
195
+ if (max < min) {
196
+ if (label != null) notice("swap", label, min, max);
197
+ const t = min;
198
+ min = max;
199
+ max = t;
200
+ }
114
201
  if (!(step > 0) || step > max - min) step = given(rawMin) || given(rawMax) ? inferStep(min, max, ...vals) : d.step;
115
202
  return { min, max, step };
116
203
  };
@@ -119,26 +206,37 @@ const normalizeRange = (rawMin: unknown, rawMax: unknown, rawStep: unknown, vals
119
206
  const rangeStep = (e: KeyboardEvent, cur: number, step: number, lo: number, hi: number, page: number | null = null) => {
120
207
  const coarse = e.shiftKey ? 10 : 1;
121
208
  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;
209
+ case "ArrowRight":
210
+ case "ArrowUp":
211
+ return cur + step * coarse;
212
+ case "ArrowLeft":
213
+ case "ArrowDown":
214
+ return cur - step * coarse;
215
+ case "PageUp":
216
+ return page != null ? cur + page : null;
217
+ case "PageDown":
218
+ return page != null ? cur - page : null;
219
+ case "Home":
220
+ return lo;
221
+ case "End":
222
+ return hi;
128
223
  }
129
224
  return null;
130
225
  };
131
226
  // Does a handle's pixel span [hx, hx+hw] overlap the label or value text? `m` is a near-miss buffer.
132
227
  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;
228
+ const ll = labelEl.offsetLeft,
229
+ lr = ll + labelEl.offsetWidth;
230
+ const vl = valueEl.offsetLeft,
231
+ vr = vl + valueEl.offsetWidth;
135
232
  return (hx < lr && hx + hw > ll) || (hx < vr + m && hx + hw > vl - m);
136
233
  };
137
234
  // Default range for a number with no bounds: 0–1 keeps 0–1, a positive spans 0–3× (100 as a
138
235
  // last resort), and a negative spans that much either side of zero, so it can still cross it
139
236
  // (rotate: -45 → −135 to 135).
140
237
  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
238
+ // NaN would span NaN–NaN; every entry point reads it as 0, as a verbose slider does
239
+ if (!Number.isFinite(v)) return [0, 1];
142
240
  const x3 = Number((Math.abs(v) * 3).toPrecision(12)); // 2.3 × 3 is 6.8999…, which the grid trimmed to 6.8
143
241
  if (v >= 0) return [0, v <= 1 ? 1 : x3 || 100];
144
242
  const m = v >= -1 ? 1 : x3;
@@ -148,26 +246,62 @@ const defaultRange = (v: number): [number, number] => {
148
246
  // An option is a string, a { value, label } object, or a bare primitive that is its own value.
149
247
  const optValue = (o: Option) => (o == null ? undefined : typeof o === "object" ? o.value : o);
150
248
  // 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); } };
249
+ const json = (v: unknown) => {
250
+ try {
251
+ return JSON.stringify(v);
252
+ } catch {
253
+ return String(v);
254
+ }
255
+ };
152
256
  // A string option shows as written ("px", "sRGB"): it's the author's text, not a key. A { value }
153
257
  // 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));
258
+ const optLabel = (o: Option) =>
259
+ o == null
260
+ ? ""
261
+ : typeof o === "string"
262
+ ? o
263
+ : typeof o === "object"
264
+ ? (o.label ?? (o.value !== undefined ? String(o.value) : json(o)))
265
+ : String(o);
155
266
 
156
267
  // The DOM factory, typed by tag like createElement (svgEl in heavy.ts is its SVG twin). A field it
157
268
  // 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");
269
+ const el = <K extends keyof HTMLElementTagNameMap>(
270
+ tag: K,
271
+ cls?: string,
272
+ html?: string,
273
+ ): Built<HTMLElementTagNameMap[K]> => {
274
+ const n = document.createElement(tag);
275
+ if (cls) n.className = cls;
276
+ if (html != null) n.innerHTML = html;
277
+ if (tag == "input" || tag == "textarea")
278
+ (n.setAttribute("autocomplete", "off"),
279
+ n.setAttribute("data-1p-ignore", ""),
280
+ n.setAttribute("data-lpignore", "true"));
161
281
  return n;
162
282
  };
163
283
  // Every <button> is type="button" — inside a host's <form> the default would submit — and
164
284
  // 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]>; };
285
+ const btn = (cls: string, html?: string) => {
286
+ const b = el("button", cls, html);
287
+ b.type = "button";
288
+ return b;
289
+ };
290
+ const txt = <K extends keyof HTMLElementTagNameMap>(tag: K, cls: string, text: string) => {
291
+ const n = tag === "button" ? btn(cls) : el(tag, cls);
292
+ n.textContent = text;
293
+ return n as Built<HTMLElementTagNameMap[K]>;
294
+ };
167
295
  // 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()));
296
+ const stopPointerLeak = (node: EventTarget) =>
297
+ ["pointerdown", "pointermove", "pointerup", "click"].forEach((t) =>
298
+ node.addEventListener(t, (e) => e.stopPropagation()),
299
+ );
169
300
  // 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); };
301
+ const onReady = (fn: () => void) => {
302
+ requestAnimationFrame(fn);
303
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(fn);
304
+ };
171
305
  // Listen on global targets for a DOM-bound owner: the first event after the owner has left
172
306
  // the document drops the listener set. "Left" means it was connected once, so an event
173
307
  // between build and the host's append is skipped, not fatal. Returns the release fn.
@@ -175,18 +309,32 @@ const onLive = (owner: Element, targets: Array<[EventTarget, string]>, fn: (e?:
175
309
  let mounted = owner.isConnected;
176
310
  // A host appends right after building, before any event can arrive: note that mount next
177
311
  // 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
312
+ if (!mounted)
313
+ requestAnimationFrame(() => {
314
+ if (owner.isConnected) mounted = true;
315
+ });
316
+ // destroy() marks the owner; release even if never mounted
317
+ const h = (e: Event) => {
318
+ if (!owner.isConnected) {
319
+ if (mounted || owner.closest("[data-twk-destroyed]")) off();
320
+ return;
321
+ }
322
+ mounted = true;
323
+ fn(e);
324
+ };
180
325
  const off = () => targets.forEach(([t, ev]) => t.removeEventListener(ev, h));
181
326
  targets.forEach(([t, ev]) => t.addEventListener(ev, h));
182
327
  return off;
183
328
  };
184
329
  // Ask every measured surface to re-measure next frame after a display:none → shown flip.
185
330
  // Namespaced, not a real "resize": host pages listen to that.
186
- const requestReflow = () => requestAnimationFrame(() => window.dispatchEvent(new Event("tw-reflow")));
331
+ const requestReflow = () => requestAnimationFrame(() => window.dispatchEvent(new Event("twk-reflow")));
187
332
  // Toggle .is-hover while the pointer is over a node; onEnter runs on entry.
188
333
  const wireHoverClass = (el: HTMLElement, onEnter: () => void) => {
189
- el.addEventListener("pointerenter", () => { el.classList.add("is-hover"); onEnter(); });
334
+ el.addEventListener("pointerenter", () => {
335
+ el.classList.add("is-hover");
336
+ onEnter();
337
+ });
190
338
  el.addEventListener("pointerleave", () => el.classList.remove("is-hover"));
191
339
  };
192
340
  // ── Quiet mouse focus — :focus-visible always matches a focused text field, so a plain
@@ -194,42 +342,78 @@ const wireHoverClass = (el: HTMLElement, onEnter: () => void) => {
194
342
  // the next focus, any key restores the ring. Installs once per page. ──
195
343
  let pointerModality = false;
196
344
  if (typeof document !== "undefined") {
197
- document.addEventListener("pointerdown", () => { pointerModality = true; }, true);
198
- document.addEventListener("keydown", () => { pointerModality = false; }, true);
345
+ document.addEventListener(
346
+ "pointerdown",
347
+ () => {
348
+ pointerModality = true;
349
+ },
350
+ true,
351
+ );
352
+ document.addEventListener(
353
+ "keydown",
354
+ () => {
355
+ pointerModality = false;
356
+ },
357
+ true,
358
+ );
199
359
  }
200
360
  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"));
361
+ input.addEventListener("focus", () => input.classList.toggle("twk-focus-quiet", pointerModality));
362
+ input.addEventListener("blur", () => input.classList.remove("twk-focus-quiet"));
203
363
  };
204
364
  /** Place a portaled popover under its trigger, flipping above when it won't fit, clamped into
205
365
  * 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" } = {}) => {
366
+ export const placeBelow = (
367
+ trigger: Element,
368
+ pop: HTMLElement,
369
+ {
370
+ width,
371
+ fallbackH = 300,
372
+ gap = 6,
373
+ align = "start",
374
+ prefer = "below",
375
+ }: {
376
+ width?: number | "match";
377
+ fallbackH?: number;
378
+ gap?: number;
379
+ align?: "start" | "end" | "center";
380
+ prefer?: "above" | "below";
381
+ } = {},
382
+ ) => {
207
383
  const r = trigger.getBoundingClientRect();
208
384
  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.
385
+ // drawn width, which min-width may hold above a match
386
+ const w = pop.offsetWidth || (width === "match" ? r.width : width || 0);
387
+ // A match held wider by min-width grows left from the trigger's right edge, so in a nested folder it spans the
388
+ // panel's row.
211
389
  const end = align === "end" || (width === "match" && w > r.width + 0.5);
212
390
  const x = end ? r.right - w : align === "center" ? r.left + r.width / 2 - w / 2 : r.left;
213
391
  pop.style.left = Math.max(8, Math.min(x, window.innerWidth - w - 8)) + "px";
214
392
  const h = pop.offsetHeight || fallbackH;
215
- const below = window.innerHeight - r.bottom, above = r.top, need = h + 12;
393
+ const below = window.innerHeight - r.bottom,
394
+ above = r.top,
395
+ need = h + 12;
216
396
  // 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));
397
+ const fitsBelow = below >= need,
398
+ fitsAbove = above >= need;
399
+ const goAbove =
400
+ prefer === "above" ? fitsAbove || (!fitsBelow && above >= below) : !fitsBelow && (fitsAbove || above > below);
219
401
  const top = goAbove ? r.top - h - gap : r.bottom + gap;
220
402
  pop.style.top = clamp(top, gap, window.innerHeight - h - gap) + "px"; // clamp Y into the viewport
221
403
  // 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));
404
+ pop.dataset.side = goAbove ? "above" : "below";
405
+ pop.style.setProperty("--twk-pop-h", String(h));
223
406
  };
224
407
 
225
- // ── Theming — the panel runs on --tw-* custom properties, so a theme is a set of those vars, which applyThemeVars
408
+ // ── Theming — the panel runs on --twk-* custom properties, so a theme is a set of those vars, which applyThemeVars
226
409
  // sets inline. A key that isn't a token is skipped out loud.
227
410
  const resolveTheme = (theme: Theme | null | undefined): ThemeVars | null => {
228
411
  if (!theme || typeof theme !== "object") return null;
229
412
  const out: ThemeVars = {};
230
413
  for (const [k, v] of Object.entries(theme)) {
231
414
  if (v == null) continue; // unset, as an optional spread leaves a key
232
- if (k.startsWith("--")) out[k] = String(v); else notice("theme", k);
415
+ if (k.startsWith("--twk-")) out[k] = String(v);
416
+ else notice("theme", k);
233
417
  }
234
418
  return Object.keys(out).length ? out : null;
235
419
  };
@@ -243,115 +427,315 @@ const applyThemeVars = (node: ThemedEl | null | undefined, vars: ThemeVars | nul
243
427
  // Carry a forced scheme onto a node portaled to <body>, where the cascade can't reach. Copy
244
428
  // the WINNING scheme, not the nearest pin: pins resolve flat (light outranks dark; tweaks.css).
245
429
  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");
430
+ const s = anchor?.closest('[data-twk-scheme="light"]')
431
+ ? "light"
432
+ : anchor?.closest('[data-twk-scheme="dark"]')
433
+ ? "dark"
434
+ : null;
435
+ if (s) portal.setAttribute("data-twk-scheme", s);
436
+ else portal.removeAttribute("data-twk-scheme");
248
437
  };
249
438
  // …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); };
439
+ // A shadow panel's portal host (shadow-root.ts) also takes the panel's tokens, which page CSS set on the panel's host.
440
+ const carrySkin = (portal: HTMLElement, anchor: Element | null | undefined) => {
441
+ applyThemeVars(portal, anchor?.closest<PanelEl>(".twk-panel")?._twTheme);
442
+ carryScheme(portal, anchor);
443
+ ((anchor?.getRootNode() as ShadowRoot | undefined)?.host as any)?._twSync?.();
444
+ };
445
+
446
+ // ── Shadow roots: a panel built with opts.shadow lives in its host's shadow root, and its portals in a second host on
447
+ // <body> (shadow-root.ts), where a transformed ancestor can't capture them. home(): where a portal from `n` goes: that
448
+ // panel's portal root, the kit's own root `n` is already in (a portal's, or a shadow panel's before its dressing
449
+ // lands), else <body>, as for a light panel inside a page's own web component.
450
+ // activeIn(): focus as `n`'s tree sees it, since document.activeElement stops at a shadow host. holds(): `t` is in
451
+ // `box`,
452
+ // or is the host `box`'s tree shows as, the relatedTarget of focus crossing between a panel's root and its portals'. ──
453
+ const home = (n: Node): Node => {
454
+ const h = (n.getRootNode() as ShadowRoot).host as any;
455
+ return h ? h._twPortal || (h.matches(".twk-host, .twk-portal-host") ? h.shadowRoot : document.body) : document.body;
456
+ };
457
+ const activeIn = (n: Node) => (n.getRootNode() as Document | ShadowRoot).activeElement;
458
+ const holds = (box: Node, t: Node | null) => !!t && (box.contains(t) || t === (box.getRootNode() as ShadowRoot).host);
459
+ // opts.shadow (a panel, mountControl, createColorPicker): `root` goes in a shadow root on a host of its own, hidden
460
+ // until shadow-root.ts has styled it. Returns what goes in the page, the promise `ready` waits for (it holds the
461
+ // dressing's own teardown), and the teardown, which waits for the dressing if a destroy() comes first.
462
+ const isolate = (root: HTMLElement, shadow: boolean | { href?: string } | undefined) => {
463
+ if (!shadow) return { box: root, end: () => {} };
464
+ const box = el("twk-host" as "div", "twk-host");
465
+ box.attachShadow({ mode: "open" }).append(root);
466
+ root.classList.add("twk-in-shadow");
467
+ box.style.visibility = "hidden";
468
+ const dressed = import("./shadow-root.js").then(
469
+ (m) => m.dress(box, root, shadow),
470
+ (e) => {
471
+ box.style.visibility = "";
472
+ console.error("[tweaks] shadow:", e);
473
+ },
474
+ );
475
+ return {
476
+ box,
477
+ dressed,
478
+ end: () => {
479
+ box.remove();
480
+ dressed.then((stop) => stop && stop());
481
+ },
482
+ };
483
+ };
484
+ // Put a portal where `from`'s portals go; in a shadow root .twk-in-shadow leaves its tokens to that root's :host.
485
+ const portalTo = (n: HTMLElement, from: Node) => {
486
+ const h = home(from);
487
+ n.classList.toggle("twk-in-shadow", h !== document.body);
488
+ if (n.parentNode !== h) h.appendChild(n);
489
+ };
251
490
 
252
491
  // ── 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
492
+ // trigger at its width (unless opts.width), carries the host theme, closes on outside-press / Esc / scroll-out.
493
+ // Globally
254
494
  // 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;
495
+ let activePopoverClose: null | (() => void) = null,
496
+ activePopoverTrigger: HTMLElement | null = null;
256
497
  // 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;
498
+ const closeActivePopover = (owner: Element) => {
499
+ if (activePopoverClose && owner.contains(activePopoverTrigger)) activePopoverClose();
500
+ };
501
+ function popover(
502
+ root: HTMLElement,
503
+ trigger: HTMLButtonElement,
504
+ pop: HTMLElement,
505
+ opts: {
506
+ width?: number | "match";
507
+ fallbackH?: number;
508
+ align?: "start" | "end";
509
+ movable?: boolean;
510
+ onOpen?: () => void;
511
+ onReflow?: () => void;
512
+ initialFocus?: () => HTMLElement | null | undefined;
513
+ } = {},
514
+ ): Popover {
515
+ let open = false,
516
+ schemeObs: MutationObserver | null = null,
517
+ removeTimer: ReturnType<typeof setTimeout> | undefined,
518
+ byPointer = false,
519
+ sizeObs: ResizeObserver | null = null;
260
520
  // Focus goes back to the row as the pane was opened: quietly after a click, even when an Escape closes it.
261
521
  const refocus = () => trigger.focus({ focusVisible: !byPointer });
262
- pop.classList.add("tw-portal"); // portal-wide CSS rules key off this
522
+ pop.classList.add("twk-portal"); // portal-wide CSS rules key off this
263
523
  // A closed pop stays in the DOM at opacity 0; inert keeps it out of the tab order and a11y tree.
264
524
  pop.inert = true;
265
525
  // Relay pointerdowns to the host panel's edit hook — on <body>, its own listener can't see them.
266
526
  stopPointerLeak(pop); // on <body>, outside the panel's own pointer-stop
267
527
  // A scroll carries the pop along at its offset from the trigger (follow) instead of
268
528
  // re-placing it; once the row has left the viewport the pop closes.
269
- let dx = 0, dy = 0;
529
+ let dx = 0,
530
+ dy = 0;
270
531
  // 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"; };
532
+ const st = { open: false, moved: false };
533
+ let dragBound = false;
534
+ const keepInView = () => {
535
+ pop.style.left = clamp(parseFloat(pop.style.left), 8, window.innerWidth - pop.offsetWidth - 8) + "px";
536
+ pop.style.top = clamp(parseFloat(pop.style.top), 8, window.innerHeight - pop.offsetHeight - 8) + "px";
537
+ };
273
538
  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;
539
+ if (st.moved) keepInView();
540
+ else placeBelow(trigger, pop, { width: opts.width ?? "match", fallbackH: opts.fallbackH, align: opts.align });
541
+ const r = trigger.getBoundingClientRect();
542
+ dx = parseFloat(pop.style.left) - r.left;
543
+ dy = parseFloat(pop.style.top) - r.top;
276
544
  };
277
545
  // 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(); } };
546
+ const reflow = () => {
547
+ if (open) {
548
+ if (!root.isConnected) return close();
549
+ place();
550
+ opts.onReflow && opts.onReflow();
551
+ }
552
+ };
279
553
  const follow = () => {
280
- if (!open) return; if (!root.isConnected) return close();
554
+ if (!open) return;
555
+ if (!root.isConnected) return close();
281
556
  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";
557
+ // a floating panel scrolls, and clips its rows (an inline one holds them all)
558
+ const sr = trigger.closest(".twk-panel")?.getBoundingClientRect();
559
+ // hidden, or scrolled out
560
+ if (
561
+ !(r.width || r.height) ||
562
+ r.bottom < 0 ||
563
+ r.top > window.innerHeight ||
564
+ r.right < 0 ||
565
+ r.left > window.innerWidth ||
566
+ (sr?.height && (r.bottom < sr.top || r.top > sr.bottom))
567
+ )
568
+ return close();
569
+ pop.style.left = r.left + dx + "px";
570
+ pop.style.top = r.top + dy + "px";
571
+ };
572
+ // the path, as a shadow host retargets e.target
573
+ const onOutside = (e: PointerEvent) => {
574
+ const p = e.composedPath();
575
+ if (!p.includes(root) && !p.includes(pop)) close();
285
576
  };
286
- const onOutside = (e: PointerEvent) => { if (!root.contains(e.target as Node) && !pop.contains(e.target as Node)) close(); };
287
577
  // 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(); } };
578
+ const onKey = (e: KeyboardEvent) => {
579
+ if (e.key === "Escape" && open && !e.defaultPrevented) {
580
+ e.preventDefault();
581
+ e.stopPropagation();
582
+ close();
583
+ refocus();
584
+ }
585
+ };
289
586
  // Focus leaving both the pop and its trigger closes. A blur with no relatedTarget is
290
587
  // ambiguous (a press on a drag surface inside the pop blurs to <body> too), so the modality
291
588
  // note decides. A Tab that lands outside the panel comes back to the trigger a tick later.
292
589
  const onFocusOut = (e: FocusEvent) => {
293
- if (!open) return; const t = e.relatedTarget as Node | null;
590
+ if (!open) return;
591
+ const t = e.relatedTarget as Node | null;
294
592
  if (!t && !document.hasFocus()) return; // the window lost focus (a file dialog, another app): the pane waits for it
295
593
  if (t ? root.contains(t) || pop.contains(t) : pointerModality) return;
594
+ // Focus crossing between a shadow panel's root and its portals' root reads as their host: look where it landed.
595
+ if (t && (holds(root, t) || holds(pop, t))) {
596
+ setTimeout(() => {
597
+ if (open && !root.contains(activeIn(root)) && !pop.contains(activeIn(pop))) close();
598
+ }, 0);
599
+ return;
600
+ }
296
601
  close();
297
- if (t && !trigger.closest(".tw-panel")?.contains(t)) setTimeout(() => { if (!open && document.activeElement === t) trigger.focus(); }, 0);
602
+ if (t && !trigger.closest(".twk-panel")?.contains(t))
603
+ setTimeout(() => {
604
+ if (!open && activeIn(t) === t) trigger.focus();
605
+ }, 0);
298
606
  };
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"])');
607
+ root.addEventListener("focusout", onFocusOut);
608
+ pop.addEventListener("focusout", onFocusOut);
609
+ const firstFocusable = () =>
610
+ pop.querySelector<HTMLElement>(
611
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
612
+ );
301
613
  // Re-carry the host scheme + theme while open: the cascade can't reach <body>, so a mid-open
302
614
  // flip would strand a mismatched popover. The observer ignores its own write on `pop`.
303
615
  const recarry = () => carrySkin(pop, root);
304
- const onScheme = (recs: MutationRecord[]) => { if (!recs.every((r) => r.target === pop)) recarry(); };
616
+ const onScheme = (recs: MutationRecord[]) => {
617
+ if (!recs.every((r) => r.target === pop)) recarry();
618
+ };
305
619
  const openPop = () => {
620
+ if (open) return; // a second open would stack a second set of observers that close() can't reach
306
621
  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), () => {}); }
622
+ activePopoverClose = close;
623
+ activePopoverTrigger = trigger;
624
+ open = st.open = true;
625
+ root.classList.add("is-open");
626
+ trigger.setAttribute("aria-expanded", "true");
627
+ // Reopened mid-fade, it's still in the page; re-appending would kill the transition, so it reverses in place.
628
+ // a pop parked in its row isn't fading
629
+ clearTimeout(removeTimer);
630
+ const fading = pop.parentNode === home(root);
631
+ st.moved = false;
632
+ byPointer = pointerModality;
633
+ pop.inert = false;
634
+ if (!fading) portalTo(pop, root);
635
+ if (!dragBound && opts.movable !== false) {
636
+ dragBound = true;
637
+ import("./heavy.js").then(
638
+ (m) => m.dragPane(pop, st, place),
639
+ () => {},
640
+ );
641
+ }
313
642
  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
643
+ window.addEventListener("twk-retheme", recarry); // setTheme() while open
644
+ // host scheme flip while open
645
+ if (typeof MutationObserver === "function") {
646
+ schemeObs = new MutationObserver(onScheme);
647
+ schemeObs.observe(document.documentElement, {
648
+ subtree: true,
649
+ attributes: true,
650
+ attributeFilter: ["data-twk-scheme"],
651
+ });
652
+ }
316
653
  // 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 = ""; }
654
+ if (fading) place();
655
+ else {
656
+ pop.style.transition = "none";
657
+ place();
658
+ void pop.offsetWidth;
659
+ pop.style.transition = "";
660
+ }
318
661
  // 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); }
662
+ if (typeof ResizeObserver === "function") {
663
+ sizeObs = new ResizeObserver(() => {
664
+ if (open) place();
665
+ });
666
+ sizeObs.observe(pop);
667
+ }
320
668
  requestAnimationFrame(() => {
321
669
  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
670
+ pop.classList.add("is-open");
671
+ opts.onOpen && opts.onOpen();
672
+ place(); // render at real size, then re-place
323
673
  // Focus moves into the pop, or Tab walks past the portaled node at the end of <body>.
324
674
  // 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 }); }
675
+ if (!pop.contains(activeIn(pop))) {
676
+ const f = (opts.initialFocus && opts.initialFocus()) || firstFocusable();
677
+ if (f) f.focus({ focusVisible: !pointerModality });
678
+ }
326
679
  });
327
680
  // Unmount watchdog: a host removing the panel while open (an SPA route change) would
328
681
  // otherwise strand the portaled pop on screen.
329
- requestAnimationFrame(function watch() { if (!open) return; if (!root.isConnected) return close(); requestAnimationFrame(watch); });
682
+ requestAnimationFrame(function watch() {
683
+ if (!open) return;
684
+ if (!root.isConnected) return close();
685
+ requestAnimationFrame(watch);
686
+ });
330
687
  // 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
688
+ // skip the opening click
689
+ setTimeout(() => {
690
+ if (open) document.addEventListener("pointerdown", onOutside, true);
691
+ }, 0);
332
692
  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
693
+ // capture: inner scrollers move the row too
694
+ window.addEventListener("scroll", follow, true);
695
+ window.addEventListener("resize", reflow);
334
696
  };
335
697
  const close = () => {
336
- if (activePopoverClose === close) { activePopoverClose = null; activePopoverTrigger = null; }
698
+ if (activePopoverClose === close) {
699
+ activePopoverClose = null;
700
+ activePopoverTrigger = null;
701
+ }
337
702
  // 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
703
+ if (pop.contains(activeIn(pop))) refocus();
704
+ open = st.open = false;
705
+ root.classList.remove("is-open");
706
+ pop.classList.remove("is-open");
707
+ pop.inert = true;
708
+ trigger.setAttribute("aria-expanded", "false");
709
+ document.removeEventListener("pointerdown", onOutside, true);
710
+ document.removeEventListener("keydown", onKey);
711
+ window.removeEventListener("scroll", follow, true);
712
+ window.removeEventListener("resize", reflow);
713
+ window.removeEventListener("twk-retheme", recarry);
714
+ if (schemeObs) {
715
+ schemeObs.disconnect();
716
+ schemeObs = null;
717
+ }
718
+ sizeObs?.disconnect();
719
+ sizeObs = null;
720
+ removeTimer = setTimeout(() => {
721
+ if (!open) pop.remove();
722
+ }, 200); // remove the portaled node once it's faded out
344
723
  };
345
- trigger.addEventListener("click", () => { if (trigger.disabled) return; open ? close() : openPop(); }); // a disabled trigger stays inert to a synthetic click
724
+ // a disabled trigger stays inert to a synthetic click
725
+ trigger.addEventListener("click", () => {
726
+ if (trigger.disabled) return;
727
+ open ? close() : openPop();
728
+ });
346
729
  return { open: openPop, close, isOpen: () => open, reflow };
347
730
  }
348
731
 
349
732
  // Fuzzy match for the filter: `q` is a substring of `text`, or within a small edit distance
350
733
  // of a window of it ("blut" finds "blur"); the DP's zeroed first row lets a match start anywhere.
351
734
 
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. ──
735
+ const REDUCE_MOTION: { matches: boolean } =
736
+ typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : { matches: false };
737
+ // ── Motion tokens — EASE_SPRING is the JS literal of the CSS --twk-ease-spring: WAAPI and
738
+ // nodes outside a .twk-panel can't read the custom property. ──
355
739
  const EASE_SPRING = "cubic-bezier(0.22, 1, 0.36, 1)";
356
740
  const EASE_GLIDE = "cubic-bezier(0.34, 1.2, 0.64, 1)"; // press glide with a light overshoot
357
741
  // Liquid pill stretch (segmented + tabs): a one-shot scaleX overshoot, origin pinned to the
@@ -359,124 +743,245 @@ const EASE_GLIDE = "cubic-bezier(0.34, 1.2, 0.64, 1)"; // press glide with a lig
359
743
  const stretchPill = (pill: HTMLElement, dir: number) => {
360
744
  if (REDUCE_MOTION.matches || typeof pill.animate !== "function") return;
361
745
  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 });
746
+ pill.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(1.18)" }, { transform: "scaleX(1)" }], {
747
+ duration: 300,
748
+ easing: EASE_SPRING,
749
+ });
363
750
  };
364
751
  // Slide a pill to the active child of `container`. The box reads come before the writes so a
365
752
  // measure can't force an extra layout; the stretch runs on a real move.
366
753
  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";
754
+ const a = container.querySelector<HTMLElement>('[data-active="true"]');
755
+ if (!a) return;
756
+ const left = a.offsetLeft,
757
+ width = a.offsetWidth,
758
+ prev = parseFloat(pill.style.left);
759
+ pill.style.left = left + "px";
760
+ pill.style.width = width + "px";
370
761
  if (animate && Number.isFinite(prev) && prev !== left) stretchPill(pill, left > prev ? 1 : -1);
371
762
  };
372
763
  // Slider rubber-band (slider + interval): dragged past an end by more than a 32px dead zone, the
373
764
  // 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;
765
+ const RUBBER_DEAD = 32,
766
+ RUBBER_RANGE = 200,
767
+ RUBBER_MAX = 8;
375
768
  const rubberBand = (track: HTMLElement, rect: DOMRect, clientX: number) => {
376
769
  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));
770
+ const s =
771
+ Math.sign(over) * RUBBER_MAX * Math.sqrt(Math.min(Math.max(0, Math.abs(over) - RUBBER_DEAD) / RUBBER_RANGE, 1));
378
772
  track.style.width = s ? `calc(100% + ${Math.abs(s)}px)` : "";
379
773
  track.style.transform = s < 0 ? `translateX(${s}px)` : "";
380
774
  };
381
775
  // Release: spring the track home, then drop the transition so the next drag tracks 1:1.
382
776
  const rubberRelease = (track: HTMLElement) => {
383
777
  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);
778
+ track.style.width = "";
779
+ track.style.transform = "";
780
+ setTimeout(() => {
781
+ track.style.transition = "";
782
+ }, 360);
386
783
  };
387
784
 
388
785
  // Disable a row: `inert` goes on the children, not the row — an inert element hit-tests like
389
786
  // pointer-events:none, so an inert row would never show its not-allowed cursor.
390
787
  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
788
+ // a closed popover child keeps its own inert
789
+ node.classList.toggle("is-disabled", d);
790
+ for (const c of node.children) if (!c.classList.contains("twk-portal")) (c as HTMLElement).inert = d;
791
+ // an open pane is portaled out of reach of inert: a control disabled mid-edit closes it
792
+ if (d) closeActivePopover(node);
393
793
  };
394
794
  // 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; };
795
+ const setCollapsed = (root: Element, toggle: Element, body: HTMLElement, c: boolean) => {
796
+ root.classList.toggle("is-collapsed", c);
797
+ toggle.setAttribute("aria-expanded", String(!c));
798
+ body.inert = c;
799
+ };
396
800
  // …and animate the fold over only the visible height: content past the height cap or the window would otherwise stall,
397
801
  // then snap. Without the Animations API, or with reduced motion, the CSS transition runs; `done` runs once settled.
398
802
  type Folding = HTMLElement & { _twFold?: Animation | null };
399
803
  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; }
804
+ if (REDUCE_MOTION.matches || typeof body.animate !== "function") {
805
+ setCollapsed(root, toggle, body, c);
806
+ if (done) setTimeout(done, 370);
807
+ return;
808
+ }
401
809
  // 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 = "";
810
+ const r = body.getBoundingClientRect(),
811
+ from = r.height; // mid-fold, the height it's drawn at
812
+ // border-box, so the cap includes the padding
813
+ const panel = root.closest<HTMLElement>(".twk-panel"),
814
+ cs = panel && getComputedStyle(panel),
815
+ maxH = cs ? parseFloat(cs.maxHeight) : NaN;
816
+ const capBottom =
817
+ cs && panel.dataset.mode === "floating" && maxH >= 0
818
+ ? panel.getBoundingClientRect().top + maxH - parseFloat(cs.paddingBottom) - parseFloat(cs.borderBottomWidth)
819
+ : Infinity;
820
+ const room = Math.max(0, Math.min(window.innerHeight, capBottom) - r.top),
821
+ easing = getComputedStyle(body).getPropertyValue("--twk-ease-fold").trim();
822
+ body._twFold?.cancel();
823
+ body._twFold = null;
824
+ body.style.transition = "none";
825
+ body.style.gridTemplateRows = "";
408
826
  setCollapsed(root, toggle, body, c);
409
827
  let to = 0; // collapsed is always 0
410
828
  if (!c) {
411
829
  // 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();
830
+ for (const a of body.firstElementChild?.getAnimations?.() ?? [])
831
+ if ((a as CSSTransition).transitionProperty !== "opacity") a.finish();
413
832
  to = body.getBoundingClientRect().height;
414
833
  }
415
- const f = Math.min(from, room), t = Math.min(to, room);
834
+ const f = Math.min(from, room),
835
+ t = Math.min(to, room);
416
836
  // 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?.(); };
837
+ const finish = () => {
838
+ body.style.gridTemplateRows = "";
839
+ void body.offsetHeight;
840
+ body._twFold?.cancel();
841
+ body._twFold = null;
842
+ body.style.transition = "";
843
+ done?.();
844
+ };
418
845
  if (Math.abs(f - t) < 1) return finish();
419
846
  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(); };
847
+ const a = (body._twFold = body.animate([{ height: f + "px" }, { height: t + "px" }], {
848
+ duration: 350,
849
+ easing,
850
+ fill: "forwards",
851
+ }));
852
+ a.onfinish = () => {
853
+ if (body._twFold === a) finish();
854
+ };
422
855
  };
423
856
  // The button whose (stringified) value is active — or, when none matches, the first: the
424
857
  // 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; };
858
+ const activeIndex = (btns: RadioBtn[], value: unknown) => {
859
+ const i = btns.findIndex((b) => b.dataset.value === String(value));
860
+ return i < 0 && btns.length ? 0 : i;
861
+ };
426
862
  // Reflect a single-select value onto a radio group: data-active, aria-checked, and a roving
427
863
  // 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; }); };
864
+ const setRadioActive = (btns: RadioBtn[], value: unknown) => {
865
+ const stop = activeIndex(btns, value);
866
+ btns.forEach((b, k) => {
867
+ const on = b.dataset.value === String(value);
868
+ b.dataset.active = String(on);
869
+ b.setAttribute("aria-checked", String(on));
870
+ b.tabIndex = k === stop ? 0 : -1;
871
+ });
872
+ };
429
873
  // A single-select radio button. _twVal carries the option's real value — dataset stringifies,
430
874
  // 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; };
875
+ const radioButton = (cls: string, o: Option, onPick: (v: string | number | undefined) => void) => {
876
+ const b: RadioBtn = txt("button", cls, optLabel(o));
877
+ b.setAttribute("role", "radio");
878
+ b._twVal = optValue(o);
879
+ b.dataset.value = String(b._twVal);
880
+ b.addEventListener("click", () => onPick(b._twVal));
881
+ return b;
882
+ };
432
883
  // Arrow-key navigation → the next index, or −1 when the key isn't the group's. cols > 0 jumps
433
884
  // ↑/↓ by a row (the radio grid); 0 treats ↑/↓ as ←/→; < 0 leaves ↑/↓ to the page (the tab bar).
434
885
  const navIndex = (key: string, i: number, n: number, cols = 0) => {
435
886
  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;
887
+ case "ArrowRight":
888
+ return (i + 1) % n;
889
+ case "ArrowLeft":
890
+ return (i - 1 + n) % n;
891
+ case "ArrowDown":
892
+ return cols < 0 ? -1 : cols ? (i + cols < n ? i + cols : i) : (i + 1) % n;
893
+ case "ArrowUp":
894
+ return cols < 0 ? -1 : cols ? (i - cols >= 0 ? i - cols : i) : (i - 1 + n) % n;
895
+ case "Home":
896
+ return 0;
897
+ case "End":
898
+ return n - 1;
442
899
  }
443
900
  return -1;
444
901
  };
445
902
  // ── 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.
903
+ // `onIs`: the boolean toggle, a two-segment row that is one switch to a screen reader (role="switch", aria-checked, one
904
+ // tab stop, Space or Enter flips it) rather than "radio button, On, 2 of 2"; its Off/On segments stay as the pointer's
905
+ // targets.
448
906
  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");
907
+ const sw = onIs !== undefined,
908
+ seg = el("div", "twk-seg");
909
+ seg.setAttribute("role", sw ? "switch" : "radiogroup");
450
910
  if (ariaLabel) seg.setAttribute("aria-label", ariaLabel);
451
911
  if (sw) seg.tabIndex = 0;
452
- const pill = el("div", "tw-seg-pill");
912
+ const pill = el("div", "twk-seg-pill");
453
913
  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
914
+ // lazy `set` — it's declared below; a clicked segment hands focus to the switch, never parks it hidden
915
+ const btns = options.map((o) => {
916
+ const b = radioButton("twk-seg-btn", o, (v) => set(v));
917
+ if (sw) {
918
+ b.setAttribute("aria-hidden", "true");
919
+ b.addEventListener("focus", () => seg.focus());
920
+ }
921
+ seg.append(b);
922
+ return b;
923
+ });
455
924
  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
925
+ const reflect = () => {
926
+ setRadioActive(btns, value);
927
+ if (sw) {
928
+ seg.setAttribute("aria-checked", String(value === onIs));
929
+ btns.forEach((b) => (b.tabIndex = -1));
930
+ }
931
+ measure(true);
932
+ };
933
+ // null clears; a value matching no segment is ignored
934
+ const set = (v: unknown, fire = true) => {
935
+ if (v != null && !btns.some((b) => b._twVal === v)) return;
936
+ value = v;
937
+ reflect();
938
+ if (fire) onChange(v);
939
+ };
458
940
  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; }
941
+ const i = activeIndex(btns, value);
942
+ if (i < 0) return;
943
+ if (sw && (e.key === " " || e.key === "Enter")) {
944
+ e.preventDefault();
945
+ set(btns[1 - i]._twVal);
946
+ return;
947
+ }
461
948
  // 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
949
+ const j = sw
950
+ ? (({ ArrowRight: 1, ArrowUp: 1, End: 1, ArrowLeft: 0, ArrowDown: 0, Home: 0 } as Record<string, number>)[
951
+ e.key
952
+ ] ?? -1)
953
+ : navIndex(e.key, i, btns.length);
954
+ if (j < 0) return;
955
+ // _twVal: the real, possibly non-string value
956
+ e.preventDefault();
957
+ set(btns[j]._twVal);
958
+ (sw ? seg : btns[j]).focus();
464
959
  });
465
960
  reflect();
466
- onReady(() => { measure(); seg.classList.add("is-ready"); }); // once laid out, again when fonts land
961
+ // once laid out, again when fonts land
962
+ onReady(() => {
963
+ measure();
964
+ seg.classList.add("is-ready");
965
+ });
467
966
  // A tab page revealing this control re-measures the pill, built at 0×0 while display:none.
468
- onLive(seg, [[window, "tw-reflow"]], () => measure());
967
+ onLive(seg, [[window, "twk-reflow"]], () => measure());
469
968
  return { el: seg, set: (v: unknown) => set(v, false), get: () => value };
470
969
  }
471
970
 
472
971
  // One stroke-icon shell for every inline SVG. It lives here, not in icons.ts, because a lazy
473
972
  // control imports it: anything both core and a lazy chunk import must sit in the shared chunk,
474
973
  // 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>`;
974
+ const icon = (body: string, cls = "", width = 2, box = 24) =>
975
+ `<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
976
 
477
977
  // The handle a display/action control returns: no value, so build skips entry/reset/persist
478
978
  // 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 });
979
+ const blade = (el: HTMLElement, destroy?: () => void): Control => ({
980
+ el,
981
+ set: () => {},
982
+ get: () => undefined,
983
+ destroy,
984
+ });
480
985
 
481
986
  // Own-key lookup for objects used as maps: "toString" must miss, not hit Object.prototype.
482
987
  const hasOwn = <T extends object>(o: T, k: PropertyKey): k is keyof T => Object.prototype.hasOwnProperty.call(o, k);
@@ -484,42 +989,149 @@ const hasOwn = <T extends object>(o: T, k: PropertyKey): k is keyof T => Object.
484
989
  const isReservedKey = (k: string) => k === "__proto__" || k === "constructor" || k === "prototype";
485
990
  // Identity for primitives, structural for the object-valued controls (a fresh object each
486
991
  // 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));
992
+ const valueChanged = (a: unknown, b: unknown) =>
993
+ a !== b && !(isObj(a) && isObj(b) && JSON.stringify(a) === JSON.stringify(b));
488
994
  // Reset: the form the control OPENED on first (it covers a value set() can't take as-is),
489
995
  // 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
996
+ // a control, or the panel's entry for one
997
+ const restoreDefault = (ctrl: Pick<Control, "set">, raw: unknown, def: unknown) => {
998
+ ctrl.set(def);
999
+ ctrl.set(raw);
1000
+ };
1001
+ // read by the spring and motion controls, and their markup
1002
+ const SPRING_KEYS = ["stiffness", "damping", "mass", "visualDuration", "bounce"];
492
1003
  // ── Control registry — type → constructor. Core controls register on load; a lazy one when
493
1004
  // 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; };
1005
+ // heterogeneous by design — see ControlCtor; null-prototype, so a type named "toString" finds nothing
1006
+ const REGISTRY: Record<string, ControlCtor<any, any>> = Object.create(null);
1007
+ export const registerControl = (type: string, ctor: ControlCtor<any, any>) => {
1008
+ REGISTRY[type] = ctor;
1009
+ };
496
1010
  export const getControl = <C = Control, M = Meta>(type: string): ControlCtor<M, C> | undefined => REGISTRY[type];
497
1011
  export const controlTypes = () => Object.keys(REGISTRY);
498
1012
  // 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;
1013
+ // any: the kit's type lives in markup.ts, which imports this
1014
+ let MARKUP: ((root: Document | Element, kit: any, strings?: Words) => Promise<void>) | null = null;
1015
+ export const setMarkup = (fn: typeof MARKUP) => {
1016
+ MARKUP = fn;
1017
+ };
1018
+ export const getMarkup = <K>() =>
1019
+ MARKUP as ((root: Document | Element, kit: K, strings?: Words) => Promise<void>) | null;
1020
+
1021
+ // ── Words: opts.strings over the English defaults, for the chrome and every control (types.ts Strings). A panel builds
1022
+ // under its strings (using()), and each control takes the set in force as it's made (words()), so a pane, note or
1023
+ // announcement it makes later speaks the same. The words function fills a key's {placeholders}, or calls a language's
1024
+ // own function with them, for plurals that need more than one form. ──
1025
+ type Word = string | ((v: any) => string);
1026
+ type Words = Strings;
1027
+ let inForce: Words = {};
1028
+ const using = <T>(s: Words | null | undefined, fn: () => T): T => {
1029
+ const was = inForce;
1030
+ inForce = s || was;
1031
+ try {
1032
+ return fn();
1033
+ } finally {
1034
+ inForce = was;
1035
+ }
1036
+ };
1037
+ // `.s` is the set it reads, for a control that rebuilds a part after its build to do so under it again (using(tr.s,
1038
+ // …)).
1039
+ const words = (s: Words | null | undefined = inForce) =>
1040
+ Object.assign(
1041
+ (k: string, d: Word, v?: Record<string, unknown>): string => {
1042
+ const w = (s as Record<string, Word | undefined> | null | undefined)?.[k] ?? d;
1043
+ return typeof w === "function"
1044
+ ? w(v)
1045
+ : v
1046
+ ? w.replace(/\{(\w+)\}/g, (m, n) => (hasOwn(v, n) ? String(v[n]) : m))
1047
+ : w;
1048
+ },
1049
+ { s },
1050
+ );
1051
+ type Tr = ReturnType<typeof words>;
502
1052
 
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;
1053
+ // What a click or a run of keys did, in words, for a screen reader: one visually hidden status region for the page.
1054
+ // It's cleared and written a beat later, so the same words twice are read twice.
1055
+ let srEl: HTMLDivElement | null = null,
1056
+ srT = 0;
506
1057
  const announce = (msg: string) => {
507
- if (!srEl) { srEl = el("div", "tw-sr"); srEl.setAttribute("role", "status"); }
1058
+ if (!srEl) {
1059
+ srEl = el("div", "twk-sr");
1060
+ srEl.setAttribute("role", "status");
1061
+ }
508
1062
  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);
1063
+ srEl.textContent = "";
1064
+ clearTimeout(srT);
1065
+ srT = setTimeout(() => {
1066
+ srEl!.textContent = msg;
1067
+ }, 100);
510
1068
  };
511
1069
 
512
1070
  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 };
1071
+ titleCase,
1072
+ clamp,
1073
+ isObj,
1074
+ isColorStr,
1075
+ stepPrecision,
1076
+ gridEnds,
1077
+ roundToStep,
1078
+ inferStep,
1079
+ defaultRange,
1080
+ normalizeRange,
1081
+ notice,
1082
+ sayNow,
1083
+ rangeStep,
1084
+ overlapsText,
1085
+ optValue,
1086
+ optLabel,
1087
+ json,
1088
+ el,
1089
+ btn,
1090
+ txt,
1091
+ stopPointerLeak,
1092
+ onReady,
1093
+ onLive,
1094
+ requestReflow,
1095
+ wireHoverClass,
1096
+ popover,
1097
+ closeActivePopover,
1098
+ resolveTheme,
1099
+ applyThemeVars,
1100
+ carryScheme,
1101
+ carrySkin,
1102
+ home,
1103
+ activeIn,
1104
+ holds,
1105
+ portalTo,
1106
+ isolate,
1107
+ setCollapsed,
1108
+ foldBody,
1109
+ setDisabled,
1110
+ activeIndex,
1111
+ setRadioActive,
1112
+ radioButton,
1113
+ navIndex,
1114
+ createSegmented,
1115
+ using,
1116
+ words,
1117
+ blade,
1118
+ quietFocus,
1119
+ measurePill,
1120
+ rubberBand,
1121
+ rubberRelease,
1122
+ REDUCE_MOTION,
1123
+ EASE_SPRING,
1124
+ EASE_GLIDE,
1125
+ icon,
1126
+ announce,
1127
+ hasOwn,
1128
+ isReservedKey,
1129
+ valueChanged,
1130
+ restoreDefault,
1131
+ SPRING_KEYS,
1132
+ };
1133
+ export type { OnChange, ThemeVars, PanelEl, RadioBtn, Built, NumSpec, NumField, Popover, ControlCtor, Words, Tr };
521
1134
  // Re-exported for the chunking: core and the gradient control both import easing.ts, and a
522
1135
  // module only those two reach would become a chunk of its own (test/chunks.test.mjs holds it
523
1136
  // to three). Core exports from easing.ts directly so the .d.ts prune skips shared.ts internals.
524
1137
  export { gradientCss, gradientStops } from "./easing.js";
525
-