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,10 +1,40 @@
1
1
  /* The core controls — always-registered constructors (slider, toggle, radio grid, select,
2
2
  * buttons, text, folder) plus createControl. Statically imported by the entry, so basic panels build synchronously. */
3
3
  import {
4
- el, btn, txt, clamp, stepPrecision, gridEnds, roundToStep, normalizeRange, rangeStep, overlapsText, optValue, optLabel, json,
5
- popover, radioButton, setRadioActive, activeIndex, navIndex, createSegmented, blade, foldBody,
6
- quietFocus, wireHoverClass, onReady, onLive, registerControl, getControl, rubberBand, rubberRelease,
7
- EASE_SPRING, EASE_GLIDE, REDUCE_MOTION,
4
+ el,
5
+ btn,
6
+ txt,
7
+ clamp,
8
+ stepPrecision,
9
+ gridEnds,
10
+ roundToStep,
11
+ normalizeRange,
12
+ rangeStep,
13
+ overlapsText,
14
+ optValue,
15
+ optLabel,
16
+ json,
17
+ popover,
18
+ radioButton,
19
+ setRadioActive,
20
+ activeIndex,
21
+ navIndex,
22
+ createSegmented,
23
+ blade,
24
+ foldBody,
25
+ quietFocus,
26
+ activeIn,
27
+ words,
28
+ wireHoverClass,
29
+ onReady,
30
+ onLive,
31
+ registerControl,
32
+ getControl,
33
+ rubberBand,
34
+ rubberRelease,
35
+ EASE_SPRING,
36
+ EASE_GLIDE,
37
+ REDUCE_MOTION,
8
38
  } from "../shared.js";
9
39
  import { ICON_CHEVRON, chevronIcon } from "../icons.js";
10
40
  import type { OnChange } from "../shared.js";
@@ -17,28 +47,41 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
17
47
  const label = meta.label;
18
48
  let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [+(meta.value as number)], label);
19
49
  // A hard slider's range is its reachable grid: the ends move in to the first/last grid point,
20
- // so notches, hashmarks, keyboard ends, aria bounds and the emitted value agree. A soft slider keeps its authored range.
50
+ // so notches, hashmarks, keyboard ends, aria bounds and the emitted value agree. A soft slider keeps its authored
51
+ // range.
21
52
  if (!meta.soft) [min, max] = gridEnds(min, max, step);
22
53
  const snap = (max - min) / step <= 6; // past ~6 stops, snapping at every step felt notchy
23
54
  const seed = Number.isFinite(+meta.value) ? +meta.value : min; // a non-finite seed → min
24
55
  // A soft slider keeps an out-of-range default, as set() does; pull is the detent's tension offset read by render().
25
- let value = meta.soft && !snap ? seed : clamp(seed, min, max), pull = 0;
56
+ let value = meta.soft && !snap ? seed : clamp(seed, min, max),
57
+ pull = 0;
26
58
  const decimals = stepPrecision(step);
27
59
 
28
- const wrap = el("div", "tw-slider-wrap");
29
- const track = el("div", "tw-slider");
30
- const hashes = el("div", "tw-slider-hashmarks");
31
- const fill = el("div", "tw-slider-fill");
32
- const handle = el("div", "tw-slider-handle");
33
- const labelEl = txt("span", "tw-slider-label", label);
34
- const valueEl = el("span", "tw-slider-value");
60
+ const wrap = el("div", "twk-slider-wrap");
61
+ const track = el("div", "twk-slider");
62
+ const hashes = el("div", "twk-slider-hashmarks");
63
+ const fill = el("div", "twk-slider-fill");
64
+ const handle = el("div", "twk-slider-handle");
65
+ const labelEl = txt("span", "twk-slider-label", label);
66
+ const valueEl = el("span", "twk-slider-value");
35
67
  // The readout is a text node plus a muted unit suffix; the param stays the bare number.
36
- const unit = typeof meta.unit === "string" ? meta.unit.trim() : "", numText = document.createTextNode("");
68
+ const unit = typeof meta.unit === "string" ? meta.unit.trim() : "",
69
+ numText = document.createTextNode("");
37
70
  const joined = !unit ? "" : /^[%°]$/.test(unit) ? unit : " " + unit; // "50%", "90°", but "24 px", "300 ms"
38
- valueEl.append(numText); if (unit) valueEl.append(txt("span", "tw-slider-unit", joined));
71
+ valueEl.append(numText);
72
+ if (unit) valueEl.append(txt("span", "twk-slider-unit", joined));
39
73
  // A format that throws or returns a non-string falls back to the digits, so it can't blank the slider.
40
- const format = meta.format, parse = meta.parse;
41
- const show = (v: number) => { if (format) { try { const s = format(v); if (typeof s === "string" && s) return s; } catch {} } return v.toFixed(decimals); };
74
+ const format = meta.format,
75
+ parse = meta.parse;
76
+ const show = (v: number) => {
77
+ if (format) {
78
+ try {
79
+ const s = format(v);
80
+ if (typeof s === "string" && s) return s;
81
+ } catch {}
82
+ }
83
+ return v.toFixed(decimals);
84
+ };
42
85
  track.append(hashes, fill, handle, labelEl, valueEl);
43
86
  wrap.append(track);
44
87
 
@@ -49,22 +92,34 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
49
92
  track.setAttribute("aria-valuemax", String(max));
50
93
 
51
94
  const q = (v: number) => roundToStep(v, min, step); // min/max sit on the grid, so a clamped value rounds inside
52
- const marks = snap ? Array.from({ length: Math.max(0, Math.round((max - min) / step) - 1) }, (_, i) => ((i + 1) * step) / (max - min) * 100) : [];
53
- for (const pct of marks) { const m = el("div", "tw-slider-hashmark"); m.style.left = pct + "%"; hashes.append(m); }
95
+ const marks = snap
96
+ ? Array.from(
97
+ { length: Math.max(0, Math.round((max - min) / step) - 1) },
98
+ (_, i) => (((i + 1) * step) / (max - min)) * 100,
99
+ )
100
+ : [];
101
+ for (const pct of marks) {
102
+ const m = el("div", "twk-slider-hashmark");
103
+ m.style.left = pct + "%";
104
+ hashes.append(m);
105
+ }
54
106
 
55
107
  // value stays continuous; only the readout + emitted value round to step.
56
108
  const render = () => {
57
- const pct = clamp(((value - min) / ((max - min) || 1)) * 100, 0, 100); // clamp the visual; a soft readout shows its real number
109
+ // clamp the visual; a soft readout shows its real number
110
+ const pct = clamp(((value - min) / (max - min || 1)) * 100, 0, 100);
58
111
  const off = pull ? ` + ${pull.toFixed(1)}px` : "";
59
112
  const edge = `${pct}%${off}`;
60
113
  fill.style.width = pull ? `calc(${edge})` : pct + "%";
61
114
  handle.style.left = `max(5px, calc(${edge} - 9px))`; // the inset hairline rides just inside the fill edge
62
- const qv = q(value), qvText = show(qv);
115
+ const qv = q(value),
116
+ qvText = show(qv);
63
117
  numText.data = qvText;
64
118
  track.setAttribute("aria-valuenow", String(qv));
65
119
  track.setAttribute("aria-valuetext", qvText + joined);
66
120
  // Value-dodge: the handle dims only while its real pixel span overlaps the label or value text.
67
- const trackW = wrap.offsetWidth, valueW = valueEl.offsetWidth;
121
+ const trackW = wrap.offsetWidth,
122
+ valueW = valueEl.offsetWidth;
68
123
  if (trackW) {
69
124
  // The label's room is what the readout leaves: 12px inset + 8px gap + the readout's 10px inset.
70
125
  if (valueW) labelEl.style.maxWidth = Math.max(0, trackW - valueW - 30) + "px";
@@ -74,7 +129,13 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
74
129
  };
75
130
  render();
76
131
 
77
- let rect: DOMRect | null = null, scale = 1, downPos: { x: number; y: number } | null = null, isClick = true, snapTimer: number, fineAnchor: { x: number; v: number } | null = null, downId: number | null = null;
132
+ let rect: DOMRect | null = null,
133
+ scale = 1,
134
+ downPos: { x: number; y: number } | null = null,
135
+ isClick = true,
136
+ snapTimer: number,
137
+ fineAnchor: { x: number; v: number } | null = null,
138
+ downId: number | null = null;
78
139
  const GLIDE_FILL = `width 0.34s ${EASE_GLIDE}`;
79
140
  const GLIDE_HANDLE = `left 0.34s ${EASE_GLIDE}, opacity 0.15s, transform 0.2s ${EASE_SPRING}`;
80
141
  // Discrete detent: the value commits at 30% of the gap and the handle springs to the notch,
@@ -86,62 +147,141 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
86
147
  const pct = clamp((clientX - rect.left) / scale / native, 0, 1);
87
148
  return clamp(min + pct * (max - min), min, max);
88
149
  };
89
- const set = (v: number | string, fire = true) => { v = +v; if (!Number.isFinite(v)) return; value = snap ? clamp(q(v), min, max) : (meta.soft ? v : clamp(v, min, max)); render(); if (fire) onChange(q(value)); }; // a non-finite value is ignored; soft may exceed the range
150
+ // a non-finite value is ignored; soft may exceed the range
151
+ const set = (v: number | string, fire = true) => {
152
+ v = +v;
153
+ if (!Number.isFinite(v)) return;
154
+ value = snap ? clamp(q(v), min, max) : meta.soft ? v : clamp(v, min, max);
155
+ render();
156
+ if (fire) onChange(q(value));
157
+ };
90
158
 
91
159
  // ── Spring-commit detent (snap sliders) ──
92
160
  // Physics in step-index space (notches at integers).
93
161
  const STEPS = Math.max(1, Math.round((max - min) / step));
94
- const C_COMMIT = 0.2, C_LEAN = 0.3, C_K = 520, C_D = 27; // C_COMMIT = 0.5 − 0.30 (commit fraction)
162
+ const C_COMMIT = 0.2,
163
+ C_LEAN = 0.3,
164
+ C_K = 520,
165
+ C_D = 27; // C_COMMIT = 0.5 − 0.30 (commit fraction)
95
166
  const toIdx = (v: number) => (v - min) / step;
96
- let sx = 0, sv = 0, sRAF = 0, sDrag = false, sPrevEi: number | null = null, cursorVal = value, sPrevT = 0;
97
- const springStop = () => { if (sRAF) cancelAnimationFrame(sRAF); sRAF = 0; sPrevT = 0; };
167
+ let sx = 0,
168
+ sv = 0,
169
+ sRAF = 0,
170
+ sDrag = false,
171
+ sPrevEi: number | null = null,
172
+ cursorVal = value,
173
+ sPrevT = 0;
174
+ const springStop = () => {
175
+ if (sRAF) cancelAnimationFrame(sRAF);
176
+ sRAF = 0;
177
+ sPrevT = 0;
178
+ };
98
179
  const springFrame = (now: number) => {
99
- if (!track.isConnected) { springStop(); return; } // panel torn down mid-drag — bail
100
- const dt = sPrevT ? Math.min((now - sPrevT) / 1000, 0.05) : 1 / 60; sPrevT = now;
180
+ // panel torn down mid-drag — bail
181
+ if (!track.isConnected) {
182
+ springStop();
183
+ return;
184
+ }
185
+ const dt = sPrevT ? Math.min((now - sPrevT) / 1000, 0.05) : 1 / 60;
186
+ sPrevT = now;
101
187
  const trackW = wrap.offsetWidth || (rect && rect.width) || 1;
102
188
  const ui = toIdx(clamp(cursorVal, min, max));
103
189
  const ei = clamp(Math.round(ui + C_COMMIT), 0, STEPS); // eager commit: 30% past the notch
104
190
  const tx = sDrag ? ei + C_LEAN * clamp(ui - ei, -0.5, 0.5) : ei; // lean toward the cursor, then settle on the notch
105
191
  // Reduced motion: the handle sits on its target every frame. The CSS kill-switch can't reach
106
192
  // this, since the detent drives left/width from rAF, not a transition. Read live.
107
- if (REDUCE_MOTION.matches) { sx = tx; sv = 0; }
108
- else { let t = dt; const h = 1 / 240; while (t > 1e-4) { const s = Math.min(h, t); t -= s; const a = C_K * (tx - sx) - C_D * sv; sv += a * s; sx += sv * s; if (sx < 0) { sx = 0; sv = 0; } if (sx > STEPS) { sx = STEPS; sv = 0; } } }
109
- pull = (sx - ei) * step / ((max - min) || 1) * trackW; // spring offset from the committed notch, in px
110
- value = clamp(min + ei * step, min, max); render();
111
- if (ei !== sPrevEi) { sPrevEi = ei; onChange(q(value)); } // emit once per commit, not per frame
112
- if (!sDrag && Math.abs(sv) < 0.004 && Math.abs(sx - ei) < 0.004) { sx = ei; sv = 0; pull = 0; render(); springStop(); track.classList.remove("is-active"); }
113
- else sRAF = requestAnimationFrame(springFrame);
193
+ if (REDUCE_MOTION.matches) {
194
+ sx = tx;
195
+ sv = 0;
196
+ } else {
197
+ let t = dt;
198
+ const h = 1 / 240;
199
+ while (t > 1e-4) {
200
+ const s = Math.min(h, t);
201
+ t -= s;
202
+ const a = C_K * (tx - sx) - C_D * sv;
203
+ sv += a * s;
204
+ sx += sv * s;
205
+ if (sx < 0) {
206
+ sx = 0;
207
+ sv = 0;
208
+ }
209
+ if (sx > STEPS) {
210
+ sx = STEPS;
211
+ sv = 0;
212
+ }
213
+ }
214
+ }
215
+ pull = (((sx - ei) * step) / (max - min || 1)) * trackW; // spring offset from the committed notch, in px
216
+ value = clamp(min + ei * step, min, max);
217
+ render();
218
+ // emit once per commit, not per frame
219
+ if (ei !== sPrevEi) {
220
+ sPrevEi = ei;
221
+ onChange(q(value));
222
+ }
223
+ if (!sDrag && Math.abs(sv) < 0.004 && Math.abs(sx - ei) < 0.004) {
224
+ sx = ei;
225
+ sv = 0;
226
+ pull = 0;
227
+ render();
228
+ springStop();
229
+ track.classList.remove("is-active");
230
+ } else sRAF = requestAnimationFrame(springFrame);
231
+ };
232
+ const springStart = (clientX: number) => {
233
+ sx = toIdx(value);
234
+ sv = 0;
235
+ sDrag = true;
236
+ sPrevEi = Math.round(toIdx(value));
237
+ cursorVal = valFromX(clientX);
238
+ if (!sRAF) sRAF = requestAnimationFrame(springFrame);
114
239
  };
115
- const springStart = (clientX: number) => { sx = toIdx(value); sv = 0; sDrag = true; sPrevEi = Math.round(toIdx(value)); cursorVal = valFromX(clientX); if (!sRAF) sRAF = requestAnimationFrame(springFrame); };
116
240
 
117
241
  track.addEventListener("pointerdown", (e) => {
118
242
  if (valueEl.classList.contains("is-editing")) return;
119
243
  if (e.button !== 0 || downPos) return; // primary button only; no second-pointer hijack
120
244
  e.preventDefault();
121
- // preventDefault suppressed click-to-focus; restore it without the keyboard ring (programmatic focus otherwise reads as keyboard).
245
+ // preventDefault suppressed click-to-focus; restore it without the keyboard ring (programmatic focus otherwise
246
+ // reads as keyboard).
122
247
  track.focus({ focusVisible: false });
123
248
  downId = e.pointerId;
124
- try { (e.target as Element).setPointerCapture(e.pointerId); } catch {}
125
- clearTimeout(snapTimer); track.style.transition = "";
126
- downPos = { x: e.clientX, y: e.clientY }; isClick = true;
249
+ try {
250
+ (e.target as Element).setPointerCapture(e.pointerId);
251
+ } catch {}
252
+ clearTimeout(snapTimer);
253
+ track.style.transition = "";
254
+ downPos = { x: e.clientX, y: e.clientY };
255
+ isClick = true;
127
256
  track.classList.add("is-active");
128
- rect = wrap.getBoundingClientRect(); scale = rect.width / (wrap.offsetWidth || rect.width);
129
- // Snap springs to the pressed notch; continuous glides there, the glide cleared on the first move so dragging stays 1:1.
257
+ rect = wrap.getBoundingClientRect();
258
+ scale = rect.width / (wrap.offsetWidth || rect.width);
259
+ // Snap springs to the pressed notch; continuous glides there, the glide cleared on the first move so dragging stays
260
+ // 1:1.
130
261
  if (snap) {
131
- fill.style.transition = ""; handle.style.transition = "";
262
+ fill.style.transition = "";
263
+ handle.style.transition = "";
132
264
  springStart(e.clientX);
133
265
  } else {
134
- fill.style.transition = GLIDE_FILL; handle.style.transition = GLIDE_HANDLE;
266
+ fill.style.transition = GLIDE_FILL;
267
+ handle.style.transition = GLIDE_HANDLE;
135
268
  set(valFromX(e.clientX));
136
269
  }
137
270
  });
138
271
  track.addEventListener("pointermove", (e) => {
139
272
  if (!downPos || e.pointerId !== downId) return;
140
273
  // Released outside the window: a captured pointerup never reaches us, so bail on the next move.
141
- if (e.buttons === 0) { up(); return; }
274
+ if (e.buttons === 0) {
275
+ up();
276
+ return;
277
+ }
142
278
  if (isClick && Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > CLICK_THRESHOLD) {
143
- isClick = false; track.classList.add("is-dragging");
144
- if (!snap) { fill.style.transition = ""; handle.style.transition = ""; }
279
+ isClick = false;
280
+ track.classList.add("is-dragging");
281
+ if (!snap) {
282
+ fill.style.transition = "";
283
+ handle.style.transition = "";
284
+ }
145
285
  }
146
286
  if (!isClick) {
147
287
  rubberBand(track, rect, e.clientX);
@@ -166,15 +306,21 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
166
306
  if (!downPos || (e && e.pointerId !== downId)) return;
167
307
  if (snap) {
168
308
  // Let the spring settle; springFrame drops is-active once it lands.
169
- sDrag = false; track.classList.remove("is-dragging");
309
+ sDrag = false;
310
+ track.classList.remove("is-dragging");
170
311
  if (!sRAF) sRAF = requestAnimationFrame(springFrame);
171
312
  } else {
172
313
  // Drop the press glide once it's home.
173
- snapTimer = setTimeout(() => { fill.style.transition = ""; handle.style.transition = ""; }, 360);
314
+ snapTimer = setTimeout(() => {
315
+ fill.style.transition = "";
316
+ handle.style.transition = "";
317
+ }, 360);
174
318
  track.classList.remove("is-active", "is-dragging");
175
319
  }
176
320
  rubberRelease(track);
177
- downPos = null; fineAnchor = null; downId = null;
321
+ downPos = null;
322
+ fineAnchor = null;
323
+ downId = null;
178
324
  };
179
325
  track.addEventListener("pointerup", up);
180
326
  track.addEventListener("pointercancel", up);
@@ -186,38 +332,70 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
186
332
  track.addEventListener("keydown", (e) => {
187
333
  if (e.target !== track) return; // keys typed into the inline editor are its own
188
334
  if (downPos) return; // keys don't steer a pointer drag
189
- if (e.key === "Enter") { e.preventDefault(); return openEditor(); }
335
+ if (e.key === "Enter") {
336
+ e.preventDefault();
337
+ return openEditor();
338
+ }
190
339
  const nv = rangeStep(e, value, step, min, max, (max - min) / 10 || step * 10);
191
340
  if (nv == null) return;
192
341
  e.preventDefault();
193
- if (snap) { springStop(); pull = 0; track.classList.remove("is-active"); } // instant step: cancel an in-flight settle + its styling
342
+ // instant step: cancel an in-flight settle + its styling
343
+ if (snap) {
344
+ springStop();
345
+ pull = 0;
346
+ track.classList.remove("is-active");
347
+ }
194
348
  set(clamp(nv, min, max));
195
349
  });
196
350
 
197
351
  // value: hover 800ms → editable; click → inline input
198
352
  let hoverTimer: number;
199
- valueEl.addEventListener("mouseenter", () => { hoverTimer = setTimeout(() => valueEl.classList.add("is-editable"), 800); });
200
- valueEl.addEventListener("mouseleave", () => { clearTimeout(hoverTimer); if (!valueEl.classList.contains("is-editing")) valueEl.classList.remove("is-editable"); });
353
+ valueEl.addEventListener("mouseenter", () => {
354
+ hoverTimer = setTimeout(() => valueEl.classList.add("is-editable"), 800);
355
+ });
356
+ valueEl.addEventListener("mouseleave", () => {
357
+ clearTimeout(hoverTimer);
358
+ if (!valueEl.classList.contains("is-editing")) valueEl.classList.remove("is-editable");
359
+ });
201
360
  valueEl.addEventListener("pointerdown", (e) => {
202
361
  // Touch/pen have no hover to arm the edit gate, so a tap on the readout arms it directly.
203
- if (e.pointerType && e.pointerType !== "mouse") { valueEl.classList.add("is-editable"); e.stopPropagation(); }
204
- else if (valueEl.classList.contains("is-editable")) e.stopPropagation();
362
+ if (e.pointerType && e.pointerType !== "mouse") {
363
+ valueEl.classList.add("is-editable");
364
+ e.stopPropagation();
365
+ } else if (valueEl.classList.contains("is-editable")) e.stopPropagation();
205
366
  });
206
367
  // The inline editor, reached from the armed readout's click or Enter on the focused track. Enter
207
- // commits and Escape cancels, both returning focus to the track; a blur commits and leaves focus where the user sent it.
368
+ // commits and Escape cancels, both returning focus to the track; a blur commits and leaves focus where the user sent
369
+ // it.
208
370
  const openEditor = () => {
209
371
  if (valueEl.classList.contains("is-editing")) return;
210
- const input = el("input", "tw-slider-input"); input.type = "text"; input.inputMode = parse ? "text" : "decimal"; quietFocus(input); /* click-to-type stays ringless; Enter from the track rings it */
211
- input.value = parse ? show(q(value)) : q(value).toFixed(decimals); // with a parse, the field edits the formatted text
212
- const opened = input.value; input.style.width = opened.length + 1 + "ch"; // read back: a text field drops line breaks
213
- valueEl.classList.add("is-editing"); valueEl.replaceWith(input); input.focus(); input.select();
214
- const restore = () => { input.replaceWith(valueEl); valueEl.classList.remove("is-editing", "is-editable"); };
372
+ const input = el("input", "twk-slider-input");
373
+ input.type = "text";
374
+ input.inputMode = parse ? "text" : "decimal";
375
+ quietFocus(input); /* click-to-type stays ringless; Enter from the track rings it */
376
+ // with a parse, the field edits the formatted text
377
+ input.value = parse ? show(q(value)) : q(value).toFixed(decimals);
378
+ // read back: a text field drops line breaks
379
+ const opened = input.value;
380
+ input.style.width = opened.length + 1 + "ch";
381
+ valueEl.classList.add("is-editing");
382
+ valueEl.replaceWith(input);
383
+ input.focus();
384
+ input.select();
385
+ const restore = () => {
386
+ input.replaceWith(valueEl);
387
+ valueEl.classList.remove("is-editing", "is-editable");
388
+ };
215
389
  // Typed text is read by `parse`, or else strictly as a whole number (not parseFloat, which reads
216
390
  // "1,000" as 1). Only a finite number commits, and never text left as it opened: a format coarser
217
391
  // than the step would move the value just by opening the editor.
218
392
  const commit = () => {
219
- const t = input.value; let p = NaN;
220
- if (t !== opened) try { p = parse ? parse(t) : Number(t.replace(unit, "").trim() || NaN); } catch {}
393
+ const t = input.value;
394
+ let p = NaN;
395
+ if (t !== opened)
396
+ try {
397
+ p = parse ? parse(t) : Number(t.replace(unit, "").trim() || NaN);
398
+ } catch {}
221
399
  if (Number.isFinite(p)) set(meta.soft ? p : clamp(p, min, max));
222
400
  restore();
223
401
  };
@@ -226,119 +404,236 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
226
404
  // Focus moves to the track BEFORE the input leaves the DOM (blur-commit unhooked so it doesn't commit
227
405
  // twice): removing a focused node first lands focus on <body>, and a focus() in the same task was dropped.
228
406
  input.addEventListener("keydown", (e) => {
229
- if (e.key === "Enter") { input.removeEventListener("blur", commit); track.focus(); commit(); }
230
- else if (e.key === "Escape") { e.stopPropagation(); input.removeEventListener("blur", commit); track.focus(); restore(); } // an Escape here mustn't also close a hosting popover
407
+ if (e.key === "Enter") {
408
+ input.removeEventListener("blur", commit);
409
+ track.focus();
410
+ commit();
411
+ }
412
+ // an Escape here mustn't also close a hosting popover
413
+ else if (e.key === "Escape") {
414
+ e.stopPropagation();
415
+ input.removeEventListener("blur", commit);
416
+ track.focus();
417
+ restore();
418
+ }
231
419
  });
232
420
  };
233
- valueEl.addEventListener("click", () => { if (valueEl.classList.contains("is-editable")) openEditor(); });
421
+ valueEl.addEventListener("click", () => {
422
+ if (valueEl.classList.contains("is-editable")) openEditor();
423
+ });
234
424
 
235
425
  return { el: wrap, set: (v: number | string) => set(v, false), get: () => q(value), range: [min, max] };
236
426
  }
237
427
 
238
428
  function createToggle(meta: Meta, onChange: OnChange): Control {
239
- const row = el("div", "tw-row");
429
+ const row = el("div", "twk-row");
240
430
  // The segmented control IS the state; this maps boolean ↔ "on"/"off" at the rim.
241
- const seg = createSegmented([{ value: "off", label: "Off" }, { value: "on", label: "On" }], meta.value ? "on" : "off", (v) => onChange(v === "on"), meta.label, "on");
242
- row.append(txt("span", "tw-row-label", meta.label), seg.el);
431
+ const tr = words(),
432
+ seg = createSegmented(
433
+ [
434
+ { value: "off", label: tr("off", "Off") },
435
+ { value: "on", label: tr("on", "On") },
436
+ ],
437
+ meta.value ? "on" : "off",
438
+ (v) => onChange(v === "on"),
439
+ meta.label,
440
+ "on",
441
+ );
442
+ row.append(txt("span", "twk-row-label", meta.label), seg.el);
243
443
  return { el: row, set: (v: unknown) => seg.set(v ? "on" : "off"), get: () => seg.get() === "on" };
244
444
  }
245
445
 
246
446
  // ── Radio grid — a segmented control wrapped into a 2- or 3-column grid for a small set of short presets. ──
247
447
  function createRadiogrid(meta: Meta, onChange: OnChange): Control {
248
448
  const options: Option[] = meta.options || [];
249
- const cols = Math.min(3, Math.max(2, meta.cols || (options.length <= 3 ? options.length : options.length === 4 ? 2 : 3)));
250
- const row = el("div", options.length > cols ? "tw-radiogrid is-grid" : "tw-radiogrid"); // several rows keep equal columns
251
- const grid = el("div", "tw-radiogrid-grid"); grid.style.setProperty("--tw-rg-cols", cols);
252
- grid.setAttribute("role", "radiogroup"); grid.setAttribute("aria-label", meta.label);
449
+ const cols = Math.min(
450
+ 3,
451
+ Math.max(2, meta.cols || (options.length <= 3 ? options.length : options.length === 4 ? 2 : 3)),
452
+ );
453
+ // several rows keep equal columns
454
+ const row = el("div", options.length > cols ? "twk-radiogrid is-grid" : "twk-radiogrid");
455
+ const grid = el("div", "twk-radiogrid-grid");
456
+ grid.style.setProperty("--twk-rg-cols", cols);
457
+ grid.setAttribute("role", "radiogroup");
458
+ grid.setAttribute("aria-label", meta.label);
253
459
  let value = meta.value ?? optValue(options[0]);
254
- const btns = options.map((o) => { const b = radioButton("tw-radiogrid-btn", o, (v) => set(v)); grid.append(b); return b; }); // lazy `set` — it's declared below
460
+ // lazy `set` — it's declared below
461
+ const btns = options.map((o) => {
462
+ const b = radioButton("twk-radiogrid-btn", o, (v) => set(v));
463
+ grid.append(b);
464
+ return b;
465
+ });
255
466
  const reflect = () => setRadioActive(btns, value);
256
- const set = (v: unknown, fire = true) => { if (v != null && v !== meta.value && !btns.some((b) => b._twVal === v)) return; value = v; reflect(); if (fire) onChange(v); }; // null clears the selection; an unknown value is ignored, bar the default written off the options, so reset() restores it
467
+ // null clears the selection; an unknown value is ignored, bar the default written off the options, so reset()
468
+ // restores it
469
+ const set = (v: unknown, fire = true) => {
470
+ if (v != null && v !== meta.value && !btns.some((b) => b._twVal === v)) return;
471
+ value = v;
472
+ reflect();
473
+ if (fire) onChange(v);
474
+ };
257
475
  grid.addEventListener("keydown", (e) => {
258
- const i = activeIndex(btns, value); if (i < 0) return;
259
- const j = navIndex(e.key, i, btns.length, cols); if (j < 0) return;
260
- e.preventDefault(); if (j !== i) { set(btns[j]._twVal); btns[j].focus(); } // _twVal, not dataset.value (which stringifies)
476
+ const i = activeIndex(btns, value);
477
+ if (i < 0) return;
478
+ const j = navIndex(e.key, i, btns.length, cols);
479
+ if (j < 0) return;
480
+ // _twVal, not dataset.value (which stringifies)
481
+ e.preventDefault();
482
+ if (j !== i) {
483
+ set(btns[j]._twVal);
484
+ btns[j].focus();
485
+ }
261
486
  });
262
487
  reflect();
263
- row.append(txt("span", "tw-radiogrid-label", meta.label), grid);
488
+ row.append(txt("span", "twk-radiogrid-label", meta.label), grid);
264
489
  return { el: row, set: (v: unknown) => set(v, false), get: () => value };
265
490
  }
266
491
 
267
492
  // ── Select ──
268
- // An option's color: one token, no nesting, so a `)` can't close it early and splice a url() into the background shorthand.
493
+ // An option's color: one token, no nesting, so a `)` can't close it early and splice a url() into the background
494
+ // shorthand.
269
495
  const COLOR_TOKEN = /^(?:#[0-9a-f]{3,8}|[a-z]+|(?:oklch|oklab|rgba?|hsla?|hwb|lab|lch|color|var)\([^()]*\))$/i;
270
- const CHEVRON = chevronIcon("tw-select-chevron");
496
+ const CHEVRON = chevronIcon("twk-select-chevron");
271
497
  function createSelect(meta: Meta, onChange: OnChange): Control {
272
498
  let value = meta.value;
273
- const opts = meta.options.map((o) => ({ value: optValue(o), label: optLabel(o), color: o && typeof o === "object" && typeof o.color === "string" && COLOR_TOKEN.test(o.color.trim()) ? o.color.trim() : "" }));
274
- const root = el("div", "tw-select");
275
- const trigger = btn("tw-select-trigger"); trigger.setAttribute("aria-haspopup", "listbox"); trigger.setAttribute("aria-expanded", "false");
276
- const right = el("span", "tw-select-right");
499
+ const opts = meta.options.map((o) => ({
500
+ value: optValue(o),
501
+ label: optLabel(o),
502
+ color:
503
+ o && typeof o === "object" && typeof o.color === "string" && COLOR_TOKEN.test(o.color.trim())
504
+ ? o.color.trim()
505
+ : "",
506
+ }));
507
+ const root = el("div", "twk-select");
508
+ const trigger = btn("twk-select-trigger");
509
+ trigger.setAttribute("aria-haspopup", "listbox");
510
+ trigger.setAttribute("aria-expanded", "false");
511
+ const right = el("span", "twk-select-right");
277
512
  // Option swatches sit over a checker so alpha reads.
278
- const swatchCss = (c: string) => `linear-gradient(${c}, ${c}), var(--tw-checker)`;
279
- const swatch = (c: string) => { const s = el("span", "tw-option-swatch"); s.style.background = swatchCss(c); return s; };
280
- const valSwatch = el("span", "tw-option-swatch"), valEl = el("span", "tw-select-value");
513
+ const swatchCss = (c: string) => `linear-gradient(${c}, ${c}), var(--twk-checker)`;
514
+ const swatch = (c: string) => {
515
+ const s = el("span", "twk-option-swatch");
516
+ s.style.background = swatchCss(c);
517
+ return s;
518
+ };
519
+ const valSwatch = el("span", "twk-option-swatch"),
520
+ valEl = el("span", "twk-select-value");
281
521
  right.append(valSwatch, valEl);
282
522
  right.insertAdjacentHTML("beforeend", CHEVRON);
283
- trigger.append(txt("span", "tw-select-label", meta.label), right);
284
- const dropdown = el("div", "tw-select-dropdown"); dropdown.setAttribute("role", "listbox");
523
+ trigger.append(txt("span", "twk-select-label", meta.label), right);
524
+ const dropdown = el("div", "twk-select-dropdown");
525
+ dropdown.setAttribute("role", "listbox");
285
526
  const optButtons = opts.map((o) => {
286
- const b = btn("tw-select-option"); b.setAttribute("role", "option"); if (o.color) b.append(swatch(o.color)); b.append(txt("span", "tw-option-label", o.label)); b.dataset.value = String(o.value);
287
- b.addEventListener("click", () => { set(o.value); pop.close(); });
288
- dropdown.append(b); return b;
527
+ const b = btn("twk-select-option");
528
+ b.setAttribute("role", "option");
529
+ if (o.color) b.append(swatch(o.color));
530
+ b.append(txt("span", "twk-option-label", o.label));
531
+ b.dataset.value = String(o.value);
532
+ b.addEventListener("click", () => {
533
+ set(o.value);
534
+ pop.close();
535
+ });
536
+ dropdown.append(b);
537
+ return b;
289
538
  });
290
539
  root.append(trigger, dropdown);
291
- const reflect = () => { const cur = opts.find((o) => o.value === value); valEl.textContent = cur?.label ?? value; valSwatch.style.display = cur?.color ? "" : "none"; if (cur?.color) valSwatch.style.background = swatchCss(cur.color); optButtons.forEach((b) => { const sel = b.dataset.value === String(value); b.dataset.selected = String(sel); b.setAttribute("aria-selected", String(sel)); }); }; // dataset stringifies, so compare as strings
292
- const set = (v: unknown, fire = true) => { if (v != null && v !== meta.value && !opts.some((o) => o.value === v)) return; value = v; reflect(); if (fire) onChange(v); }; // null clears the selection; an unknown value is ignored, bar the default written off the options, so reset() restores it
540
+ // dataset stringifies, so compare as strings
541
+ const reflect = () => {
542
+ const cur = opts.find((o) => o.value === value);
543
+ valEl.textContent = cur?.label ?? value;
544
+ valSwatch.style.display = cur?.color ? "" : "none";
545
+ if (cur?.color) valSwatch.style.background = swatchCss(cur.color);
546
+ optButtons.forEach((b) => {
547
+ const sel = b.dataset.value === String(value);
548
+ b.dataset.selected = String(sel);
549
+ b.setAttribute("aria-selected", String(sel));
550
+ });
551
+ };
552
+ // null clears the selection; an unknown value is ignored, bar the default written off the options, so reset()
553
+ // restores it
554
+ const set = (v: unknown, fire = true) => {
555
+ if (v != null && v !== meta.value && !opts.some((o) => o.value === v)) return;
556
+ value = v;
557
+ reflect();
558
+ if (fire) onChange(v);
559
+ };
293
560
  // The shared popover shell: portaled to <body>, closes on outside-press / Esc / scroll-away,
294
561
  // and opening it closes any other popover.
295
- let typed = "", typedAt = 0; // the type-ahead buffer; a fresh open starts it over, as a native popup does
562
+ let typed = "",
563
+ typedAt = 0; // the type-ahead buffer; a fresh open starts it over, as a native popup does
296
564
  const pop = popover(root, trigger, dropdown, {
297
- fallbackH: 200, movable: false, // a menu: it closes on a pick, so it never stays in the way
298
- onOpen: () => { typed = ""; (optButtons.find((b) => b.dataset.value === String(value)) || optButtons[0])?.focus(); },
565
+ fallbackH: 200,
566
+ movable: false, // a menu: it closes on a pick, so it never stays in the way
567
+ onOpen: () => {
568
+ typed = "";
569
+ (optButtons.find((b) => b.dataset.value === String(value)) || optButtons[0])?.focus();
570
+ },
299
571
  });
300
572
  // Enter/Space on a focused option selects it natively via click; Tab away closes by the shell's focusout rule.
301
573
  trigger.addEventListener("keydown", (e) => {
302
- if (!pop.isOpen() && (e.key === "ArrowDown" || e.key === "ArrowUp")) { e.preventDefault(); pop.open(); }
574
+ if (!pop.isOpen() && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
575
+ e.preventDefault();
576
+ pop.open();
577
+ }
303
578
  });
304
579
  dropdown.addEventListener("keydown", (e) => {
305
- const i = optButtons.indexOf(document.activeElement as HTMLButtonElement); let j = i;
306
- const inBeat = !!typed && performance.now() - typedAt < 600; // monotonic, so a wall-clock step can't pin the beat open
580
+ const i = optButtons.indexOf(activeIn(dropdown) as HTMLButtonElement);
581
+ let j = i;
582
+ // monotonic, so a wall-clock step can't pin the beat open
583
+ const inBeat = !!typed && performance.now() - typedAt < 600;
307
584
  if (e.key === "ArrowDown") j = i < 0 ? 0 : Math.min(optButtons.length - 1, i + 1);
308
585
  else if (e.key === "ArrowUp") j = i < 0 ? optButtons.length - 1 : Math.max(0, i - 1);
309
586
  else if (e.key === "Home") j = 0;
310
587
  else if (e.key === "End") j = optButtons.length - 1;
311
- else if (e.key.length === 1 && (e.key !== " " || inBeat) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
312
- // Type-ahead, as a native select: keys within a beat form a prefix that moves focus to the first label starting with it,
313
- // the same key over and over cycles its matches, and no match leaves focus where it is. A space joins a prefix under way
314
- // and is never also the option's activation; on its own it stays the option's.
315
- const k = e.key.toLowerCase(); typed = (inBeat ? typed : "") + k; typedAt = performance.now();
316
- const one = typed === k.repeat(typed.length / k.length), needle = one ? k : typed; // compared per key, since "İ" lowercases to two code units
317
- const hit = (lo: number) => opts.findIndex((o, n) => n >= lo && String(o.label).toLowerCase().trimStart().startsWith(needle));
318
- j = hit(one ? i + 1 : i); if (j < 0) j = hit(0); // a lone key moves past the focused option, a growing prefix re-tests it; the second pass wraps
588
+ else if (
589
+ e.key.length === 1 &&
590
+ (e.key !== " " || inBeat) &&
591
+ !e.ctrlKey &&
592
+ !e.metaKey &&
593
+ !e.altKey &&
594
+ !e.isComposing
595
+ ) {
596
+ // Type-ahead, as a native select: keys within a beat form a prefix that moves focus to the first label starting
597
+ // with it, the same key over and over cycles its matches, and no match leaves focus where it is. A space joins a
598
+ // prefix under way and is never also the option's activation; on its own it stays the option's.
599
+ const k = e.key.toLowerCase();
600
+ typed = (inBeat ? typed : "") + k;
601
+ typedAt = performance.now();
602
+ // compared per key, since "İ" lowercases to two code units
603
+ const one = typed === k.repeat(typed.length / k.length),
604
+ needle = one ? k : typed;
605
+ const hit = (lo: number) =>
606
+ opts.findIndex((o, n) => n >= lo && String(o.label).toLowerCase().trimStart().startsWith(needle));
607
+ // a lone key moves past the focused option, a growing prefix re-tests it; the second pass wraps
608
+ j = hit(one ? i + 1 : i);
609
+ if (j < 0) j = hit(0);
319
610
  if (j < 0 && e.key !== " ") return; // a prefix's space is swallowed even unmatched, so it never also picks
320
- }
321
- else return;
322
- e.preventDefault(); optButtons[j]?.focus();
611
+ } else return;
612
+ e.preventDefault();
613
+ optButtons[j]?.focus();
323
614
  });
324
615
  reflect();
325
616
  return { el: root, set: (v: unknown) => set(v, false), get: () => value };
326
617
  }
327
618
 
328
619
  function createButton(meta: Meta) {
329
- const b = txt("button", "tw-button", meta.label);
330
- b.addEventListener("click", () => typeof meta.action === "function" && meta.action()); // a verbose form can carry a non-function action; as the group does, ignore it
620
+ const b = txt("button", "twk-button", meta.label);
621
+ // a verbose form can carry a non-function action; as the group does, ignore it
622
+ b.addEventListener("click", () => typeof meta.action === "function" && meta.action());
331
623
  return blade(b);
332
624
  }
333
625
 
334
626
  // ── Button group — a row of compact actions under one label. `buttons` is { label: fn } or [{label, action}]. ──
335
627
  function createButtonGroup(meta: Meta) {
336
- const row = el("div", "tw-row tw-buttongroup");
337
- if (meta.label) row.append(txt("span", "tw-row-label", meta.label));
338
- const group = el("div", "tw-buttongroup-btns");
339
- const list: [string, () => void][] = Array.isArray(meta.buttons) ? meta.buttons.map((b) => [b.label ?? "Button", b.action]) : Object.entries(meta.buttons || {}); // an entry with no label is still a button, not an empty pill
628
+ const row = el("div", "twk-row twk-buttongroup");
629
+ if (meta.label) row.append(txt("span", "twk-row-label", meta.label));
630
+ const group = el("div", "twk-buttongroup-btns");
631
+ // an entry with no label is still a button, not an empty pill
632
+ const list: [string, () => void][] = Array.isArray(meta.buttons)
633
+ ? meta.buttons.map((b) => [b.label ?? words()("button", "Button"), b.action])
634
+ : Object.entries(meta.buttons || {});
340
635
  for (const [lab, fn] of list) {
341
- const b = txt("button", "tw-buttongroup-btn", lab);
636
+ const b = txt("button", "twk-buttongroup-btn", lab);
342
637
  b.addEventListener("click", () => typeof fn === "function" && fn());
343
638
  group.append(b);
344
639
  }
@@ -347,7 +642,7 @@ function createButtonGroup(meta: Meta) {
347
642
  }
348
643
 
349
644
  // ── Separator — a thin divider to break a long panel into sections. ──
350
- const createSeparator = () => blade(el("div", "tw-separator"));
645
+ const createSeparator = () => blade(el("div", "twk-separator"));
351
646
 
352
647
  // ── String — a labeled text input ──
353
648
  function createString(meta: Meta, onChange: OnChange): Control {
@@ -355,39 +650,68 @@ function createString(meta: Meta, onChange: OnChange): Control {
355
650
  const str = (v: unknown) => (v == null ? "" : typeof v === "object" ? json(v) : String(v));
356
651
  let value = str(meta.value);
357
652
  const multi = meta.rows > 0;
358
- const row = el("div", multi ? "tw-row tw-row-multiline" : "tw-row");
359
- const input = el(multi ? "textarea" : "input", multi ? "tw-text tw-textarea" : "tw-text");
360
- if (multi) (input as HTMLTextAreaElement).rows = meta.rows; else (input as HTMLInputElement).type = "text";
653
+ const row = el("div", multi ? "twk-row twk-row-multiline" : "twk-row");
654
+ const input = el(multi ? "textarea" : "input", multi ? "twk-text twk-textarea" : "twk-text");
655
+ if (multi) (input as HTMLTextAreaElement).rows = meta.rows;
656
+ else (input as HTMLInputElement).type = "text";
361
657
  input.value = value;
362
658
  quietFocus(input); // click-to-type stays ringless; Tab rings
363
659
  if (meta.placeholder) input.placeholder = meta.placeholder;
364
- input.addEventListener("input", () => { value = input.value; onChange(value); });
660
+ input.addEventListener("input", () => {
661
+ value = input.value;
662
+ onChange(value);
663
+ });
365
664
  // Long text clips softly: the edge with more past it fades (a mask, so the fade is the panel's own backdrop).
366
- const clip = () => { const more = input.scrollHeight - input.clientHeight > 1; input.classList.toggle("is-clip-top", more && input.scrollTop > 1); input.classList.toggle("is-clip-bottom", more && input.scrollTop + input.clientHeight < input.scrollHeight - 1); };
367
- if (multi) { input.addEventListener("scroll", clip); input.addEventListener("input", clip); if (typeof ResizeObserver === "function") new ResizeObserver(clip).observe(input); }
368
- row.append(txt("span", "tw-row-label", meta.label), input);
369
- return { el: row, set: (v: unknown) => { value = str(v); input.value = value; if (multi) clip(); }, get: () => value }; // null → "", not the literal "undefined"
665
+ const clip = () => {
666
+ const more = input.scrollHeight - input.clientHeight > 1;
667
+ input.classList.toggle("is-clip-top", more && input.scrollTop > 1);
668
+ input.classList.toggle("is-clip-bottom", more && input.scrollTop + input.clientHeight < input.scrollHeight - 1);
669
+ };
670
+ if (multi) {
671
+ input.addEventListener("scroll", clip);
672
+ input.addEventListener("input", clip);
673
+ if (typeof ResizeObserver === "function") new ResizeObserver(clip).observe(input);
674
+ }
675
+ row.append(txt("span", "twk-row-label", meta.label), input);
676
+ // null → "", not the literal "undefined"
677
+ return {
678
+ el: row,
679
+ set: (v: unknown) => {
680
+ value = str(v);
681
+ input.value = value;
682
+ if (multi) clip();
683
+ },
684
+ get: () => value,
685
+ };
370
686
  }
371
687
 
372
688
  // ── Folder — a collapsible titled group; `body` is the inner container the caller fills. ──
373
689
  function createFolder(meta: { label: string }) {
374
- const root = el("div", "tw-folder");
375
- const header = btn("tw-folder-header"); header.setAttribute("aria-expanded", "true");
376
- header.append(txt("span", "tw-folder-title", meta.label));
690
+ const root = el("div", "twk-folder");
691
+ const header = btn("twk-folder-header");
692
+ header.setAttribute("aria-expanded", "true");
693
+ header.append(txt("span", "twk-folder-title", meta.label));
377
694
  header.insertAdjacentHTML("beforeend", ICON_CHEVRON); // chevron trails the title
378
- const body = el("div", "tw-folder-body");
379
- const inner = el("div", "tw-controls"); body.append(inner);
695
+ const body = el("div", "twk-folder-body");
696
+ const inner = el("div", "twk-controls");
697
+ body.append(inner);
380
698
  root.append(header, body);
381
- const collapse = (c: boolean) => foldBody(root, header, body, c); // the shared fold: class + aria-expanded + inert on the body
699
+ // the shared fold: class + aria-expanded + inert on the body
700
+ const collapse = (c: boolean) => foldBody(root, header, body, c);
382
701
  header.addEventListener("click", () => collapse(!root.classList.contains("is-collapsed")));
383
702
  return { el: root, body: inner, setCollapsed: collapse };
384
703
  }
385
- /** Builds a control from the registry; a constructor that throws degrades to null so one bad control can't abort the panel. */
704
+ /** Builds a control from the registry; a constructor that throws degrades to null so one bad control can't abort the
705
+ * panel. */
386
706
  function createControl(meta: Meta, onChange: OnChange): Control | null {
387
707
  const make = getControl(meta.type);
388
708
  if (!make) return null;
389
- try { return make(meta, onChange); }
390
- catch (e) { console.error("[tweaks] control failed to build:", meta && meta.type, e); return null; }
709
+ try {
710
+ return make(meta, onChange);
711
+ } catch (e) {
712
+ console.error("[tweaks] control failed to build:", meta && meta.type, e);
713
+ return null;
714
+ }
391
715
  }
392
716
  // Core controls register synchronously; lazy ones register when imported.
393
717
  registerControl("slider", createSlider);