reviewzy 0.3.0 → 0.4.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.
@@ -1,4 +1,4 @@
1
- /* Controllers. Every component that needs script, in one module.
1
+ /* controllers. Every component that needs script, in one module.
2
2
  Load as `<script type="module" src="ui.js"></script>`. It wires itself on load
3
3
  against whatever markup is present and does nothing where a component is absent,
4
4
  so one script tag is correct for every page in the system.
@@ -36,6 +36,50 @@ const once = (el, key) => {
36
36
  const animMs = el => (parseFloat(getComputedStyle(el).animationDuration) || 0) * 1000 + 20;
37
37
  const transMs = el => Math.max(...getComputedStyle(el).transitionDuration.split(',').map(parseFloat)) * 1000;
38
38
 
39
+ // the control a row-level click must not answer for, because its own handler already will
40
+ const isControl = t => t.closest?.('button, a, input, select, textarea, label');
41
+
42
+ const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
43
+
44
+ // keeps a row inside its own scroll box without scrollIntoView, which would also scroll
45
+ // the page when the box sits near a viewport edge
46
+ const scrollIntoBox = (box, top, bottom) => {
47
+ if (top < box.scrollTop) box.scrollTop = top;
48
+ else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
49
+ };
50
+
51
+ /* The query rule both filtering lists run on: every word has to land. The tokenizer is
52
+ its own step because the palette's empty message names the fix from the COUNT:
53
+ another word for a one-word query, fewer for the rest. */
54
+ const queryWords = typed => typed.toLowerCase().split(/\s+/).filter(Boolean);
55
+ const matchesQuery = (hay, words) => words.every(w => hay.includes(w));
56
+
57
+ /* The exit every overlay in this file runs: the `.closing` costume, held for `clock`,
58
+ then hidden with the costume dropped on the same frame. `clock` is a function because
59
+ the duration belongs to the costume, so it can only be read once the class is on: a
60
+ modal panel's own exit is declared at `.modal-backdrop.closing .modal`, which needs
61
+ the class on the BACKDROP too. The timer comes back to the caller, since a reopen
62
+ mid-exit has to clear it. */
63
+ const runExit = (el, panel, clock) => {
64
+ el.classList.add('closing');
65
+ panel?.classList.add('closing');
66
+ return setTimeout(() => {
67
+ el.hidden = true;
68
+ el.classList.remove('closing');
69
+ panel?.classList.remove('closing');
70
+ }, clock());
71
+ };
72
+
73
+ // a press inside a controller belongs to that controller, so only the others close.
74
+ // Controllers are dropped when their root leaves the DOM: a re-render that replaces
75
+ // a tip or a select leaves a dead entry here, and every outside press then walks it.
76
+ const closeOnOutside = controllers => document.addEventListener('pointerdown', e => {
77
+ for (let i = controllers.length - 1; i >= 0; i--) {
78
+ if (!controllers[i].root.isConnected) { controllers.splice(i, 1); continue; }
79
+ if (!controllers[i].root.contains(e.target)) controllers[i].close();
80
+ }
81
+ });
82
+
39
83
  /* ====================== sliding inks (tabs, navbar) ====================== */
40
84
 
41
85
  /* First placement lands in place: with the transform transition live, an ink
@@ -88,12 +132,16 @@ function placeFrom(wrapEl, inkEl, activeSel) {
88
132
  a page writes the wrap and gets the whole component. Where a marker sits is
89
133
  part of it: these are positioned with no z-index, so paint order is document
90
134
  order, and one that paints UNDER its siblings goes first. Existing markup is
91
- reused, so a page that still writes one never gets a second. */
92
- function ownChild(parent, cls, { tag = 'div', first = false } = {}) {
93
- let el = parent.querySelector(`:scope > .${cls}`);
135
+ reused, so a page that still writes one never gets a second. `svg` builds in
136
+ the SVG namespace, where `className` is read-only, so the class is an
137
+ attribute for both. */
138
+ const SVG_NS = 'http://www.w3.org/2000/svg';
139
+ function ownChild(parent, cls, { tag = 'div', first = false, svg = false } = {}) {
140
+ const sel = `:scope > .${cls.trim().split(/\s+/).join('.')}`;
141
+ let el = parent.querySelector(sel);
94
142
  if (!el) {
95
- el = document.createElement(tag);
96
- el.className = cls;
143
+ el = svg ? document.createElementNS(SVG_NS, tag) : document.createElement(tag);
144
+ el.setAttribute('class', cls);
97
145
  el.setAttribute('aria-hidden', 'true');
98
146
  if (first) parent.prepend(el); else parent.append(el);
99
147
  }
@@ -128,6 +176,17 @@ function ownLabel(parent, cls) {
128
176
  return el;
129
177
  }
130
178
 
179
+ // a wrapper is not a child: the component's own node has to end up inside it
180
+ function ownWrap(el, cls) {
181
+ const have = el.parentElement;
182
+ if (have && cls.trim().split(/\s+/).every(t => have.classList.contains(t))) return have;
183
+ const wrap = document.createElement('div');
184
+ wrap.className = cls;
185
+ el.parentNode.insertBefore(wrap, el);
186
+ wrap.appendChild(el);
187
+ return wrap;
188
+ }
189
+
131
190
  // a resize or a scroll moves the ink without animating the slide: the indicator is
132
191
  // following its tab, not travelling to a new one
133
192
  function replace(inkEl, run) {
@@ -146,6 +205,8 @@ function replace(inkEl, run) {
146
205
  and the control just moves its own marker, which is what a segmented control
147
206
  with no panels needs. Keying only on `data-tabs` is why every page carrying a
148
207
  panel-less boxed control had a hand-written copy of this. */
208
+ const TAB_WRAP = '.tabs-wrap, .tabs-boxed-wrap';
209
+
149
210
  export function initTabs() {
150
211
  /* examples/dashboard.html is what this cost: its wrap held no pill, so the
151
212
  segments cross-faded their own backgrounds instead. The two read identical
@@ -153,21 +214,34 @@ export function initTabs() {
153
214
  click, the sheet's pill at translate 24.55 on its way to 42.42 against the
154
215
  dashboard's nothing. The boxed pill paints under its segments, so it goes
155
216
  first. */
156
- const inkFor = wrap => wrap && (wrap.classList.contains('tabs-boxed-wrap')
157
- ? ownChild(wrap, 'tabs-boxed-ink', { first: true })
158
- : ownChild(wrap, 'tab-ink'));
159
- const wraps = () => document.querySelectorAll('.tabs-wrap, .tabs-boxed-wrap');
217
+ /* The underline ink rides the TAB ROW, never the wrap: a wrap may hold its
218
+ panels too (the sheet's specimen nests them), and the row is the box the
219
+ ink underlines whatever else the wrap contains. The boxed pill's track is
220
+ the wrap itself, so the two branch here. */
221
+ const inkFor = wrap => {
222
+ if (!wrap) return null;
223
+ if (wrap.classList.contains('tabs-boxed-wrap')) return ownChild(wrap, 'tabs-boxed-ink', { first: true });
224
+ // no row, no ink: an ink with no rail resolves against an ancestor the
225
+ // component does not own
226
+ const tabsRow = wrap.querySelector(':scope > .tabs');
227
+ if (!tabsRow) return null;
228
+ // the rail is hoisted, never queried inline: the builder args are parsed with
229
+ // a flat scan that closes at the first `)`
230
+ return ownChild(tabsRow, 'tab-ink');
231
+ };
232
+ const wraps = () => document.querySelectorAll(TAB_WRAP);
160
233
  const placeWrap = wrap => {
161
234
  const ink = inkFor(wrap);
162
235
  if (!ink) return;
163
236
  const boxed = ink.classList.contains('tabs-boxed-ink');
164
- placeFrom(wrap, ink, boxed ? '.tab-box.active' : '.tab.active');
237
+ // the rail is the ink's own parent, so geometry and paint cannot disagree
238
+ placeFrom(ink.parentElement, ink, boxed ? '.tab-box.active' : '.tab.active');
165
239
  if (boxed) wrap.classList.add('has-ink');
166
240
  };
167
241
 
168
242
  document.querySelectorAll('.tab, .tab-box').forEach(btn => {
169
243
  if (!once(btn, 'uiTab')) return;
170
- const wrap = btn.closest('.tabs-wrap, .tabs-boxed-wrap');
244
+ const wrap = btn.closest(TAB_WRAP);
171
245
  const kind = btn.classList.contains('tab-box') ? '.tab-box' : '.tab';
172
246
  btn.addEventListener('click', () => {
173
247
  const group = btn.dataset.tabs;
@@ -185,7 +259,7 @@ export function initTabs() {
185
259
  if (panel) panel.classList.add('active');
186
260
 
187
261
  // a data-tabs group can span wraps, so every wrap holding a peer re-places
188
- new Set(peers.map(b => b.closest('.tabs-wrap, .tabs-boxed-wrap')).filter(Boolean))
262
+ new Set(peers.map(b => b.closest(TAB_WRAP)).filter(Boolean))
189
263
  .forEach(placeWrap);
190
264
  });
191
265
  });
@@ -285,18 +359,28 @@ export function initDrawer() {
285
359
  const hamburger = document.getElementById('hamburger-btn');
286
360
  if (!sidebar) return;
287
361
 
362
+ /* Under 768 the column is the off-canvas drawer, and a transform moves paint, never focus:
363
+ without `inert` its rows stay in the tab order behind a closed drawer. Above 768 the same
364
+ column is in flow and always reachable, so the flag follows the query as well as the state. */
365
+ const narrow = matchMedia('(max-width: 768px)');
366
+ const setInert = () => { sidebar.inert = narrow.matches && !sidebar.classList.contains('open'); };
288
367
  const setOpen = open => {
289
368
  sidebar.classList.toggle('open', open);
290
369
  overlay?.classList.toggle('open', open);
291
370
  hamburger?.setAttribute('aria-expanded', String(open));
371
+ const held = sidebar.contains(document.activeElement);
372
+ setInert();
373
+ if (!open && held) hamburger?.focus();
292
374
  };
375
+ narrow.addEventListener('change', setInert);
376
+ setInert();
293
377
  if (hamburger && once(hamburger, 'uiDrawer')) hamburger.addEventListener('click', () => setOpen(true));
294
378
  if (overlay && once(overlay, 'uiDrawer')) overlay.addEventListener('click', () => setOpen(false));
295
379
  if (once(sidebar, 'uiDrawerRows')) {
296
380
  document.querySelectorAll('.nav-item, .navbar-link').forEach(item => {
297
381
  item.addEventListener('click', () => { if (innerWidth <= 768) setOpen(false); });
298
382
  });
299
- addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); });
383
+ addEventListener('keydown', e => { if (e.key === 'Escape' && sidebar.classList.contains('open')) setOpen(false); });
300
384
  }
301
385
  }
302
386
 
@@ -305,14 +389,12 @@ export function initDrawer() {
305
389
  /* A `.toggle` is a real button carrying `aria-pressed`, so the state and the
306
390
  semantics move together and never disagree.
307
391
 
308
- THE ROW IS THE TARGET, her call 2026-08-29: a coarse pointer gets its 44px on
392
+ THE ROW IS THE TARGET: a coarse pointer gets its 44px on
309
393
  the row and the control keeps its proportions, so the row has to be clickable
310
394
  or that floor buys nothing. The guard skips a click that landed on another
311
395
  control, so the control's own handler runs once and the row cannot double-toggle
312
396
  it. Every page carrying a settings panel had written this by hand. */
313
397
  export function initToggles() {
314
- const isControl = t => t.closest?.('button, a, input, select, textarea, label');
315
-
316
398
  document.querySelectorAll('.toggle').forEach(t => {
317
399
  if (t.disabled || !once(t, 'uiToggle')) return;
318
400
  t.addEventListener('click', () => {
@@ -366,6 +448,10 @@ export function initSelect() {
366
448
  rendered yet) would leave this in its dead zone and throw at the first
367
449
  chip click. */
368
450
  const dead = () => trigger.disabled || trigger.getAttribute('aria-disabled') === 'true';
451
+ // the same dead zone: a chip's remove announces, and it is wired at construction
452
+ const announce = () => root.dispatchEvent(
453
+ new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
454
+ const selectOnly = opt => options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
369
455
 
370
456
  // one value surface per shape: the shipped .select-value, an <input>
371
457
  // trigger's value (combobox), or the chips in a multi-select trigger
@@ -373,7 +459,7 @@ export function initSelect() {
373
459
  read: () => valueEl.textContent.trim(),
374
460
  value: () => valueEl.textContent.trim(),
375
461
  pick(opt) {
376
- options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
462
+ selectOnly(opt);
377
463
  valueEl.textContent = opt.textContent.trim();
378
464
  return true;
379
465
  },
@@ -389,7 +475,7 @@ export function initSelect() {
389
475
  // query a blur left behind cannot pin the panel to one match
390
476
  onOpen: () => { trigger.value = committed; showAll(); },
391
477
  pick(opt) {
392
- options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
478
+ selectOnly(opt);
393
479
  committed = opt.textContent.trim();
394
480
  trigger.value = committed;
395
481
  return true;
@@ -403,8 +489,6 @@ export function initSelect() {
403
489
  const picked = () => options.filter(o => o.getAttribute('aria-selected') === 'true')
404
490
  .map(o => o.textContent.trim()).join(', ');
405
491
  const sync = () => { root.dataset.value = picked(); };
406
- const announce = () => root.dispatchEvent(
407
- new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
408
492
  const removeChip = chip => {
409
493
  if (dead()) return;
410
494
  const label = chip.querySelector('span')?.textContent ?? '';
@@ -470,6 +554,31 @@ export function initSelect() {
470
554
  trigger.setAttribute('aria-controls', menu.id);
471
555
  options.forEach((o, j) => { o.id ||= `select-${i}-opt-${j}`; });
472
556
 
557
+ // the width floor: the trigger's text is only the selected value, so left
558
+ // to itself a select in a flex row sizes to it and the open panel outgrows
559
+ // the shut state. Measured after the row reaches the document (a detached
560
+ // tree has no layout, so offsetWidth reads 0) and again through an explicit
561
+ // fonts.load, since fonts.ready can resolve before a lazy face even starts.
562
+ // Pinned at the panel's corner so its box never extends the scroller's
563
+ // overflow. The floor is the widest option plus the menu's gutter and border.
564
+ const sizer = document.createElement('span');
565
+ sizer.className = 'menu-item';
566
+ sizer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;top:0;left:0;';
567
+ menu.appendChild(sizer);
568
+ const fitLongest = () => {
569
+ if (!root.isConnected) return;
570
+ let widest = 0;
571
+ options.forEach(opt => {
572
+ sizer.textContent = opt.textContent;
573
+ widest = Math.max(widest, sizer.offsetWidth);
574
+ });
575
+ root.style.minWidth = (widest + 10) + 'px';
576
+ };
577
+ requestAnimationFrame(fitLongest);
578
+ document.fonts.ready.then(() => {
579
+ document.fonts.load(getComputedStyle(sizer).font).then(fitLongest);
580
+ });
581
+
473
582
  const visible = () => options.filter(o => !o.hidden);
474
583
  const selectedVisible = () => {
475
584
  const vis = visible();
@@ -480,19 +589,18 @@ export function initSelect() {
480
589
  // the value surface exists from load, or a consumer reading it before the
481
590
  // first pick disagrees with what aria-selected already announces
482
591
  root.dataset.value = adapter.read();
592
+ // the surface is written before the event, or a consumer reading it from the
593
+ // change handler still sees the previous pick
594
+ const commit = () => { root.dataset.value = adapter.value(); announce(); };
483
595
  let closingTimer = 0;
484
596
  const isOpen = () => root.classList.contains('open');
485
597
  // read off --fold, the move itself, so a token change or reduced motion
486
598
  // retimes the hold without touching this number
487
599
  const exitMs = () => transMs(root);
488
600
 
489
- // keeps the arrow-key row inside the scroll box without scrollIntoView,
490
- // which would also scroll the page when the panel sits near an edge
491
601
  const revealFocused = opt => {
492
602
  const top = opt.offsetTop;
493
- const bottom = top + opt.offsetHeight;
494
- if (top < menu.scrollTop) menu.scrollTop = top;
495
- else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
603
+ scrollIntoBox(menu, top, top + opt.offsetHeight);
496
604
  };
497
605
 
498
606
  const markFocus = idx => {
@@ -542,17 +650,13 @@ export function initSelect() {
542
650
  const pick = opt => {
543
651
  if (!opt) return;
544
652
  if (adapter.pick(opt)) close({ focus: true });
545
- root.dataset.value = adapter.value();
546
- root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
653
+ commit();
547
654
  };
548
655
 
549
656
  const filter = () => {
550
- const typed = trigger.value.trim();
551
- // every word has to land, the same rule the palette filters its rows by
552
- const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
657
+ const words = queryWords(trigger.value.trim());
553
658
  options.forEach(opt => {
554
- const hay = opt.textContent.trim().toLowerCase();
555
- opt.hidden = !words.every(w => hay.includes(w));
659
+ opt.hidden = !matchesQuery(opt.textContent.trim().toLowerCase(), words);
556
660
  });
557
661
  if (empty) empty.hidden = visible().length > 0;
558
662
  markFocus(selectedVisible());
@@ -600,18 +704,13 @@ export function initSelect() {
600
704
  e.stopPropagation();
601
705
  if (dead()) return;
602
706
  adapter.clear();
603
- root.dataset.value = adapter.value();
604
- root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
707
+ commit();
605
708
  });
606
709
 
607
710
  selectControllers.push({ root, close });
608
711
  });
609
712
 
610
- if (once(document.body, 'uiSelectOutside')) {
611
- document.addEventListener('pointerdown', e => {
612
- selectControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
613
- });
614
- }
713
+ if (once(document.body, 'uiSelectOutside')) closeOnOutside(selectControllers);
615
714
  }
616
715
 
617
716
  /* ============================= palette ============================ */
@@ -620,6 +719,66 @@ export function initSelect() {
620
719
  listbox, so the arrow-key row is named by aria-activedescendant. The controller
621
720
  owns nothing a command does: it dispatches `run` on the backdrop with the row's
622
721
  data-command. Ctrl/Cmd+K opens it whether or not a trigger button exists. */
722
+ const VISIBLE_ROW = '.palette-row:not([hidden])';
723
+
724
+ export function initSlider() {
725
+ document.querySelectorAll('.slider').forEach(root => {
726
+ if (!once(root, 'uiSlider')) return;
727
+ const track = root.querySelector('.slider-track');
728
+ const fill = root.querySelector('.slider-fill');
729
+ if (!track || !fill) return; // the controller never throws on half-built markup
730
+ const min = parseFloat(root.getAttribute('aria-valuemin') ?? '0');
731
+ const max = parseFloat(root.getAttribute('aria-valuemax') ?? '100');
732
+ let val = parseFloat(root.getAttribute('aria-valuenow') ?? String(min));
733
+ const commit = () => {
734
+ root.setAttribute('aria-valuenow', String(val));
735
+ fill.style.width = ((val - min) / (max - min)) * 100 + '%';
736
+ };
737
+ const setVal = (v, fire) => {
738
+ val = Math.min(max, Math.max(min, v));
739
+ commit();
740
+ if (fire) root.dispatchEvent(new Event('input', { bubbles: true }));
741
+ };
742
+ const valFromX = clientX => {
743
+ const r = track.getBoundingClientRect();
744
+ return min + ((clientX - r.left) / r.width) * (max - min);
745
+ };
746
+ root.addEventListener('pointerdown', e => {
747
+ e.preventDefault();
748
+ root.focus();
749
+ setVal(valFromX(e.clientX), true);
750
+ // a synthetic event carries no active pointer and the capture throws,
751
+ // which would also bail the ring through its error listener
752
+ try { root.setPointerCapture(e.pointerId); } catch {}
753
+ root.dataset.dragging = '1';
754
+ });
755
+ root.addEventListener('pointermove', e => {
756
+ if (root.dataset.dragging) setVal(valFromX(e.clientX), true);
757
+ });
758
+ const end = e => {
759
+ if (!root.dataset.dragging) return;
760
+ delete root.dataset.dragging;
761
+ try { if (root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId); } catch {}
762
+ };
763
+ root.addEventListener('pointerup', end);
764
+ root.addEventListener('pointercancel', end);
765
+ root.addEventListener('keydown', e => {
766
+ const step =
767
+ e.key === 'ArrowUp' || e.key === 'ArrowRight' ? 1 :
768
+ e.key === 'ArrowDown' || e.key === 'ArrowLeft' ? -1 :
769
+ e.key === 'PageUp' ? 10 :
770
+ e.key === 'PageDown' ? -10 :
771
+ e.key === 'Home' ? min - val :
772
+ e.key === 'End' ? max - val : 0;
773
+ if (!step) return;
774
+ e.preventDefault();
775
+ setVal(val + step, true);
776
+ });
777
+ Object.defineProperty(root, 'value', { get: () => String(val) });
778
+ commit();
779
+ });
780
+ }
781
+
623
782
  export function initPalette() {
624
783
  // the sheet's own catalogue anchor is named `palette`, so an id lookup finds
625
784
  // the section that documents the component instead of the live palette
@@ -652,18 +811,12 @@ export function initPalette() {
652
811
  // .closing counts as closed: a second Escape mid-exit must not restart the
653
812
  // exit, and a reopen mid-exit must not return early
654
813
  const isOpen = () => !backdrop.hidden && !backdrop.classList.contains('closing');
655
- const exitMs = () => animMs(panel);
656
814
 
657
- // keeps the active row inside the scroll box without scrollIntoView, which
658
- // would scroll the page under the palette as well. A row that opens its
659
- // group scrolls the group in, or Home lands on a cut-off label.
815
+ // a row that opens its group scrolls the group in, or Home lands on a cut-off label
660
816
  const reveal = row => {
661
817
  const group = row.parentElement;
662
- const opensGroup = row === group.querySelector('.palette-row:not([hidden])');
663
- const top = (opensGroup ? group : row).offsetTop;
664
- const bottom = row.offsetTop + row.offsetHeight;
665
- if (top < list.scrollTop) list.scrollTop = top;
666
- else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
818
+ const opensGroup = row === group.querySelector(VISIBLE_ROW);
819
+ scrollIntoBox(list, (opensGroup ? group : row).offsetTop, row.offsetTop + row.offsetHeight);
667
820
  };
668
821
 
669
822
  const markActive = i => {
@@ -679,16 +832,14 @@ export function initPalette() {
679
832
 
680
833
  const filter = () => {
681
834
  const typed = input.value.trim();
682
- // every word has to land, so the hint names the actual fix: another word
683
- // for a one-word query, fewer for the rest
684
- const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
835
+ const words = queryWords(typed);
685
836
  shown = [];
686
837
  rows.forEach(row => {
687
- const hit = words.every(w => row.dataset.hay.includes(w));
838
+ const hit = matchesQuery(row.dataset.hay, words);
688
839
  row.hidden = !hit;
689
840
  if (hit) shown.push(row);
690
841
  });
691
- groups.forEach(g => { g.hidden = !g.querySelector('.palette-row:not([hidden])'); });
842
+ groups.forEach(g => { g.hidden = !g.querySelector(VISIBLE_ROW); });
692
843
  const none = !shown.length;
693
844
  list.hidden = none;
694
845
  if (empty) empty.hidden = !none;
@@ -723,14 +874,8 @@ export function initPalette() {
723
874
 
724
875
  const close = () => {
725
876
  if (!isOpen()) return;
726
- backdrop.classList.add('closing');
727
- panel.classList.add('closing');
728
877
  clearTimeout(closeTimer);
729
- closeTimer = setTimeout(() => {
730
- backdrop.hidden = true;
731
- backdrop.classList.remove('closing');
732
- panel.classList.remove('closing');
733
- }, exitMs());
878
+ closeTimer = runExit(backdrop, panel, () => animMs(panel));
734
879
  // focus goes back where it came from, or to the control that names the palette
735
880
  const back = lastFocus && lastFocus.isConnected && lastFocus !== document.body ? lastFocus : trigger;
736
881
  back?.focus();
@@ -792,7 +937,6 @@ export function initTooltip() {
792
937
 
793
938
  let hovering = false, focused = false, hideTimer = 0, hoverTimer = 0;
794
939
  const isOpen = () => !tip.hidden;
795
- const exitMs = () => animMs(tip);
796
940
 
797
941
  const place = () => {
798
942
  const r = root.getBoundingClientRect();
@@ -814,12 +958,8 @@ export function initTooltip() {
814
958
  };
815
959
  const close = () => {
816
960
  if (!isOpen()) return;
817
- tip.classList.add('closing');
818
961
  clearTimeout(hideTimer);
819
- hideTimer = setTimeout(() => {
820
- tip.hidden = true;
821
- tip.classList.remove('closing');
822
- }, exitMs());
962
+ hideTimer = runExit(tip, null, () => animMs(tip));
823
963
  };
824
964
  const maybeClose = () => { if (!hovering && !focused) close(); };
825
965
 
@@ -838,7 +978,12 @@ export function initTooltip() {
838
978
  });
839
979
  // touch has no hover: the first contact shows it
840
980
  trigger.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') open(); });
841
- trigger.addEventListener('focus', () => { focused = true; open(); });
981
+ trigger.addEventListener('focus', () => {
982
+ // click focuses the trigger too, and a pointer focus must not count as the
983
+ // show condition: :focus-visible separates keyboard focus from pointer focus
984
+ focused = trigger.matches(':focus-visible');
985
+ if (focused) open();
986
+ });
842
987
  trigger.addEventListener('blur', () => { focused = false; maybeClose(); });
843
988
  trigger.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
844
989
 
@@ -846,9 +991,7 @@ export function initTooltip() {
846
991
  });
847
992
 
848
993
  if (once(document.body, 'uiTooltipGlobal')) {
849
- document.addEventListener('pointerdown', e => {
850
- tooltipControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
851
- });
994
+ closeOnOutside(tooltipControllers);
852
995
  // an open tooltip re-places on resize; it is anchored to its wrapper,
853
996
  // so scrolling carries it with the page
854
997
  addEventListener('resize', () => {
@@ -938,14 +1081,8 @@ export function initModal() {
938
1081
  if (backdrop.hidden || backdrop.classList.contains('closing')) return;
939
1082
  const s = state.get(backdrop) || {};
940
1083
  const panel = backdrop.querySelector('.modal');
941
- backdrop.classList.add('closing');
942
- panel?.classList.add('closing');
943
1084
  clearTimeout(s.timer);
944
- s.timer = setTimeout(() => {
945
- backdrop.hidden = true;
946
- backdrop.classList.remove('closing');
947
- panel?.classList.remove('closing');
948
- }, panel ? animMs(panel) : 180);
1085
+ s.timer = runExit(backdrop, panel, () => (panel ? animMs(panel) : 180));
949
1086
  s.inert?.forEach(el => { el.inert = false; });
950
1087
  s.inert = null;
951
1088
  state.set(backdrop, s);
@@ -1003,7 +1140,7 @@ export function showToast(type, title, msg) {
1003
1140
  </button>`;
1004
1141
  t.querySelector('.toast-title').textContent = title;
1005
1142
  t.querySelector('.toast-msg').textContent = msg;
1006
- t.querySelector('.close').addEventListener('click', () => removeToast(t));
1143
+ wireDismiss(t.querySelector('.close'));
1007
1144
  stack.appendChild(t);
1008
1145
  setTimeout(() => removeToast(t), 5000);
1009
1146
  return t;
@@ -1015,26 +1152,29 @@ export function removeToast(t) {
1015
1152
  setTimeout(() => t.remove(), 110); // --dur-out-near, the toast's own exit
1016
1153
  }
1017
1154
 
1018
- /* Dismiss controls that already sit in the markup: a toast written into the page,
1019
- and the page-level banner. `.close` is the one remove. */
1020
- export function initDismiss() {
1021
- document.querySelectorAll('.close').forEach(btn => {
1022
- if (!once(btn, 'uiDismiss')) return;
1023
- btn.addEventListener('click', () => {
1024
- const toast = btn.closest('.toast');
1025
- if (toast) return removeToast(toast);
1026
- const banner = btn.closest('.banner');
1027
- if (!banner) return;
1028
- banner.classList.add('closing');
1029
- setTimeout(() => banner.remove(), animMs(banner));
1030
- });
1155
+ /* `.close` is the one remove, for a toast and for the page-level banner alike. A toast
1156
+ this file builds is wired here at creation, because nothing re-runs init() after it:
1157
+ the sweep below only ever sees the controls already sitting in the markup. */
1158
+ const wireDismiss = btn => {
1159
+ if (!once(btn, 'uiDismiss')) return;
1160
+ btn.addEventListener('click', () => {
1161
+ const toast = btn.closest('.toast');
1162
+ if (toast) return removeToast(toast);
1163
+ const banner = btn.closest('.banner');
1164
+ if (!banner) return;
1165
+ banner.classList.add('closing');
1166
+ setTimeout(() => banner.remove(), animMs(banner));
1031
1167
  });
1168
+ };
1169
+
1170
+ export function initDismiss() {
1171
+ document.querySelectorAll('.close').forEach(wireDismiss);
1032
1172
  }
1033
1173
 
1034
1174
  /* ============================== theme ============================= */
1035
1175
 
1036
1176
  /* The saved theme is restored in <head> before the stylesheet loads, so a light
1037
- visitor never sees a dark flash; that snippet cannot live
1177
+ visitor never sees a dark flash; that snippet is in SKILL.md and cannot live
1038
1178
  here, because a module runs too late to beat the first paint. */
1039
1179
  export function setTheme(t) {
1040
1180
  document.documentElement.dataset.theme = t;
@@ -1120,11 +1260,61 @@ export function initSticky() {
1120
1260
  });
1121
1261
  }
1122
1262
 
1263
+ /* Plays each target once, in batches of what arrives together: when it crosses
1264
+ the line 10% above the bottom edge, or when it is on screen and no scroller
1265
+ holding it can lift it any higher. Without the second clause a short element
1266
+ at a page's end, or one low on a page that cannot scroll, stays hidden for
1267
+ good: measured, a 40px chart as the sheet's last element sat at 859-899px of
1268
+ a 900px window at full scroll, under the 810px line. Returns the call that
1269
+ plays a batch from outside, for the reveal's net. */
1270
+ function watchEntrance(targets, play) {
1271
+ const pending = new Set(targets);
1272
+ const onScreen = new Set();
1273
+ const settle = els => {
1274
+ const batch = els.filter(el => pending.delete(el));
1275
+ batch.forEach(el => {
1276
+ onScreen.delete(el);
1277
+ line.unobserve(el);
1278
+ seen.unobserve(el);
1279
+ });
1280
+ if (!pending.size) {
1281
+ removeEventListener('scroll', sweep, true);
1282
+ removeEventListener('resize', sweep);
1283
+ }
1284
+ if (batch.length) play(batch);
1285
+ };
1286
+ function sweep() { settle([...onScreen].filter(cannotRise)); }
1287
+ const line = new IntersectionObserver(
1288
+ entries => settle(entries.filter(e => e.isIntersecting).map(e => e.target)),
1289
+ { threshold: 0, rootMargin: '0px 0px -10% 0px' });
1290
+ const seen = new IntersectionObserver(entries => {
1291
+ entries.forEach(e => e.isIntersecting ? onScreen.add(e.target) : onScreen.delete(e.target));
1292
+ sweep();
1293
+ }, { threshold: 0 });
1294
+ addEventListener('scroll', sweep, { capture: true, passive: true });
1295
+ addEventListener('resize', sweep);
1296
+ targets.forEach(el => {
1297
+ line.observe(el);
1298
+ seen.observe(el);
1299
+ });
1300
+ return settle;
1301
+ }
1302
+
1303
+ /* every scroller holding the element already sits at its end */
1304
+ const cannotRise = el => {
1305
+ for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
1306
+ const overflowY = getComputedStyle(n).overflowY;
1307
+ const scrolls = overflowY === 'auto' || overflowY === 'scroll';
1308
+ if (scrolls && n.scrollTop < n.scrollHeight - n.clientHeight - 1) return false;
1309
+ }
1310
+ const page = document.scrollingElement;
1311
+ return page.scrollTop >= page.scrollHeight - page.clientHeight - 1;
1312
+ };
1313
+
1123
1314
  /* JS adds the hiding class at runtime, so no-JS keeps the content visible.
1124
1315
  Reduced motion skips the hide entirely rather than zeroing a duration. */
1125
1316
  export function initReveal() {
1126
- const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
1127
- if (reduced || !('IntersectionObserver' in window)) return;
1317
+ if (reducedMotion() || !('IntersectionObserver' in window)) return;
1128
1318
 
1129
1319
  const targets = [...document.querySelectorAll('[data-reveal]')].filter(el => once(el, 'uiReveal'));
1130
1320
  if (!targets.length) return;
@@ -1138,20 +1328,14 @@ export function initReveal() {
1138
1328
  const land = els => els.forEach((el, i) => {
1139
1329
  el.style.transitionDelay = Math.min(i, 5) * 40 + 'ms';
1140
1330
  el.classList.add('in');
1141
- io.unobserve(el);
1142
1331
  });
1143
-
1144
- const io = new IntersectionObserver(
1145
- entries => land(entries.filter(e => e.isIntersecting).map(e => e.target)),
1146
- { threshold: 0, rootMargin: '0px 0px -10% 0px' });
1147
- targets.forEach(el => io.observe(el));
1332
+ const settle = watchEntrance(targets, land);
1148
1333
 
1149
1334
  /* The net covers an observer that never fired, so it reveals only what is ON
1150
1335
  SCREEN when it lands. Revealing every target instead settles the whole page
1151
1336
  before the reader reaches any of it: measured on the rendered sheet, 44 of
1152
1337
  46 sections were off screen at 1500ms, so no section could ever animate. */
1153
- setTimeout(() => land(targets.filter(el => {
1154
- if (el.classList.contains('in')) return false;
1338
+ setTimeout(() => settle(targets.filter(el => {
1155
1339
  const r = el.getBoundingClientRect();
1156
1340
  return r.top < innerHeight && r.bottom > 0;
1157
1341
  })), 1500);
@@ -1181,7 +1365,7 @@ function initCardButtons() {
1181
1365
  });
1182
1366
  }
1183
1367
 
1184
- /* A legend built from buttons is a control, and the design docs had described this
1368
+ /* A legend built from buttons is a control, and `charts.md` had described this
1185
1369
  one for a round before anything wrote it: every key on every page was inert
1186
1370
  text beside a swatch. The toggle names its slot with the same
1187
1371
  `chart-series-N` class the mark carries, so the pair cannot drift, and it
@@ -1202,8 +1386,56 @@ function initLegend() {
1202
1386
  });
1203
1387
  }
1204
1388
 
1389
+ /* A line's area and its end point are the line's own geometry, so a page writes
1390
+ the polyline and this derives the other two: the flat tint down to the plot's
1391
+ floor, under the line, and the round mark on its last point, over it. Both are
1392
+ read off the `points` attribute rather than kept, so a page that redraws a line
1393
+ rewrites that one attribute and the pair follows; an emptied or removed line
1394
+ takes its pair with it, or a tint would outlive the line it stood under. A write through
1395
+ the `SVGPointList` API fires no mutation, measured in Chrome, so it redraws
1396
+ nothing. A sparkline takes the same pair. A `<path>` line has no point list to
1397
+ read, and gets neither. */
1398
+ let lineMarks = null;
1399
+ function drawLineMarks(line) {
1400
+ const n = line.points?.numberOfItems;
1401
+ const box = line.ownerSVGElement?.viewBox.baseVal;
1402
+ const parent = line.parentNode;
1403
+ if (!parent) return;
1404
+ if (!n || !box?.height) {
1405
+ parent.querySelectorAll(':scope > .chart-area, :scope > .chart-point.chart-end').forEach(m => m.remove());
1406
+ return;
1407
+ }
1408
+ const pts = Array.from({ length: n }, (_, i) => line.points.getItem(i));
1409
+ const floor = box.y + box.height;
1410
+ const first = pts[0];
1411
+ const last = pts[n - 1];
1412
+ const area = ownChild(parent, 'chart-area', { tag: 'polygon', svg: true, first: true });
1413
+ area.setAttribute('points',
1414
+ [...pts.map(p => `${p.x},${p.y}`), `${last.x},${floor}`, `${first.x},${floor}`].join(' '));
1415
+ const end = ownChild(parent, 'chart-point chart-end', { tag: 'line', svg: true });
1416
+ end.setAttribute('x1', last.x);
1417
+ end.setAttribute('y1', last.y);
1418
+ end.setAttribute('x2', last.x);
1419
+ end.setAttribute('y2', last.y);
1420
+ }
1421
+ function initLineMarks() {
1422
+ document.querySelectorAll('.chart-series > .chart-line, .stat-spark svg > polyline').forEach(line => {
1423
+ if (!once(line, 'uiLineMarks')) return;
1424
+ lineMarks ||= new MutationObserver(records => records.forEach(r => {
1425
+ if (r.type === 'attributes') return drawLineMarks(r.target);
1426
+ if (![...r.removedNodes].some(n => n.matches?.('.chart-line, polyline'))) return;
1427
+ if (r.target.querySelector(':scope > .chart-line, :scope > polyline')) return;
1428
+ r.target.querySelectorAll(':scope > .chart-area, :scope > .chart-point.chart-end').forEach(m => m.remove());
1429
+ }));
1430
+ drawLineMarks(line);
1431
+ lineMarks.observe(line, { attributes: true, attributeFilter: ['points'] });
1432
+ lineMarks.observe(line.parentNode, { childList: true });
1433
+ });
1434
+ }
1435
+
1205
1436
  export function initChart() {
1206
1437
  initLegend();
1438
+ initLineMarks();
1207
1439
  const charts = [...document.querySelectorAll('.chart')].filter(el => once(el, 'uiChart'));
1208
1440
  if (!charts.length) return;
1209
1441
 
@@ -1214,15 +1446,7 @@ export function initChart() {
1214
1446
  charts.forEach(el => el.classList.add('is-drawn'));
1215
1447
  return;
1216
1448
  }
1217
- const io = new IntersectionObserver(entries => {
1218
- entries.forEach(e => {
1219
- if (e.isIntersecting) {
1220
- e.target.classList.add('is-drawn');
1221
- io.unobserve(e.target);
1222
- }
1223
- });
1224
- }, { threshold: 0, rootMargin: '0px 0px -10% 0px' });
1225
- charts.forEach(el => io.observe(el));
1449
+ watchEntrance(charts, els => els.forEach(el => el.classList.add('is-drawn')));
1226
1450
  }
1227
1451
 
1228
1452
  /* ========================== popovers and menus ========================== */
@@ -1240,38 +1464,126 @@ export function initChart() {
1240
1464
  const popoverPairs = [];
1241
1465
 
1242
1466
  export function initPopovers() {
1243
- const isControl = t => t.closest?.('button, a, input, select, textarea, label');
1244
1467
  const close = (menu, btn) => {
1245
1468
  if (menu.hidden || menu.classList.contains('closing')) return;
1246
- menu.classList.add('closing');
1247
1469
  btn?.setAttribute('aria-expanded', 'false');
1248
- setTimeout(() => { menu.hidden = true; menu.classList.remove('closing'); }, animMs(menu));
1470
+ menu.querySelectorAll('.menu-item.focus').forEach(r => r.classList.remove('focus'));
1471
+ menu.uiPopoverExit = runExit(menu, null, () => animMs(menu));
1249
1472
  };
1250
1473
 
1251
1474
  document.querySelectorAll('[aria-controls][aria-haspopup]').forEach(btn => {
1252
1475
  const menu = document.getElementById(btn.getAttribute('aria-controls'));
1253
1476
  if (!menu || !menu.classList.contains('popover') || !once(btn, 'uiPopover')) return;
1254
1477
  popoverPairs.push({ btn, menu, close });
1478
+
1479
+ const show = () => {
1480
+ clearTimeout(menu.uiPopoverExit);
1481
+ menu.classList.remove('closing');
1482
+ menu.hidden = false;
1483
+ btn.setAttribute('aria-expanded', 'true');
1484
+ };
1485
+
1255
1486
  // a checkbox row has to survive several picks, so an input inside the
1256
1487
  // item keeps the menu open; a button row is an action, and an action
1257
1488
  // row that opens a modal leaves a menu open behind the dialog, whose
1258
1489
  // Escape handler then eats the dialog's
1259
1490
  menu.addEventListener('click', e => {
1260
1491
  const item = e.target.closest?.('.menu-item');
1261
- if (!item || isControl(e.target)) return;
1262
- close(menu, btn);
1263
- btn.focus();
1264
- });
1265
- menu.addEventListener('click', e => {
1266
- if (!e.target.closest?.('button.menu-item')) return;
1492
+ if (!item || (isControl(e.target) && !e.target.closest?.('button.menu-item'))) return;
1267
1493
  close(menu, btn);
1268
1494
  btn.focus();
1269
1495
  });
1270
1496
  btn.addEventListener('click', () => {
1271
1497
  if (!menu.hidden) return close(menu, btn);
1272
- menu.classList.remove('closing');
1273
- menu.hidden = false;
1274
- btn.setAttribute('aria-expanded', 'true');
1498
+ show();
1499
+ });
1500
+
1501
+ // the keyboard model and the row focus belong to the menu costume alone;
1502
+ // a bare popover keeps the base open / outside-click / Escape / exit
1503
+ if (!menu.classList.contains('popover-menu')) return;
1504
+
1505
+ const rows = [...menu.querySelectorAll('.menu-item')];
1506
+ // the menu-button pattern: the button is the one Tab stop, rows are focusable
1507
+ // by script only, and a checkbox row's input is never a second target; the
1508
+ // box stays the state and the row's aria-checked mirrors it
1509
+ rows.forEach(row => {
1510
+ row.tabIndex = -1;
1511
+ const input = row.querySelector('input');
1512
+ if (input) {
1513
+ input.tabIndex = -1;
1514
+ // the menuitemcheckbox row is the one checkbox a screen reader hears;
1515
+ // the input stays the state and is never announced or Tab-stopped
1516
+ input.setAttribute('aria-hidden', 'true');
1517
+ const sync = () => row.setAttribute('aria-checked', String(input.checked));
1518
+ sync();
1519
+ input.addEventListener('change', sync);
1520
+ }
1521
+ });
1522
+ let focusIdx = 0;
1523
+ const isOpen = () => !menu.hidden && !menu.classList.contains('closing');
1524
+ const activeIndex = () => {
1525
+ const at = rows.findIndex(r => r.classList.contains('active'));
1526
+ return at >= 0 ? at : 0;
1527
+ };
1528
+ const focusRow = idx => {
1529
+ if (!rows.length) return;
1530
+ focusIdx = ((idx % rows.length) + rows.length) % rows.length;
1531
+ rows[focusIdx].focus();
1532
+ };
1533
+ // the .focus mark follows the real focus wherever it lands, and the row is
1534
+ // the only thing that ever holds focus: a pointer click focuses the checkbox
1535
+ // input, which is aria-hidden, so focus moves to its row instead
1536
+ menu.addEventListener('focusin', e => {
1537
+ const row = e.target.closest?.('.menu-item');
1538
+ if (!row) return;
1539
+ if (e.target.matches?.('input')) { row.focus(); return; }
1540
+ const pos = rows.indexOf(row);
1541
+ if (pos >= 0) {
1542
+ focusIdx = pos;
1543
+ rows.forEach(r => r.classList.toggle('focus', r === rows[pos]));
1544
+ }
1545
+ });
1546
+ // Enter/Space fire the row's own click, so a consumer's listener on it runs;
1547
+ // the menu's click listener above then closes it. A checkbox row toggles its
1548
+ // box and stays open.
1549
+ const activate = () => {
1550
+ const row = rows[focusIdx];
1551
+ if (!row) return;
1552
+ const input = row.querySelector('input');
1553
+ if (input) { input.click(); return; }
1554
+ row.click();
1555
+ };
1556
+ const open = (toLast = false) => {
1557
+ if (isOpen()) return;
1558
+ show();
1559
+ focusRow(toLast ? rows.length - 1 : activeIndex());
1560
+ };
1561
+
1562
+ // focus is on the button: closed keys open, and a pointer-opened menu moves
1563
+ // focus into the rows on the first arrow
1564
+ btn.addEventListener('keydown', e => {
1565
+ switch (e.key) {
1566
+ case 'ArrowDown': e.preventDefault(); isOpen() ? focusRow(activeIndex()) : open(false); break;
1567
+ case 'ArrowUp': e.preventDefault(); isOpen() ? focusRow(rows.length - 1) : open(true); break;
1568
+ case 'Home': if (isOpen()) { e.preventDefault(); focusRow(0); } break;
1569
+ case 'End': if (isOpen()) { e.preventDefault(); focusRow(rows.length - 1); } break;
1570
+ case 'Enter':
1571
+ case ' ': e.preventDefault(); isOpen() ? close(menu, btn) : open(false); break;
1572
+ case 'Tab': close(menu, btn); break;
1573
+ }
1574
+ });
1575
+ // focus is on a row: the arrows walk it, Enter/Space activate, Escape is the
1576
+ // global handler's, and Tab closes while the default moves focus on
1577
+ menu.addEventListener('keydown', e => {
1578
+ switch (e.key) {
1579
+ case 'ArrowDown': e.preventDefault(); focusRow(focusIdx + 1); break;
1580
+ case 'ArrowUp': e.preventDefault(); focusRow(focusIdx - 1); break;
1581
+ case 'Home': e.preventDefault(); focusRow(0); break;
1582
+ case 'End': e.preventDefault(); focusRow(rows.length - 1); break;
1583
+ case 'Enter':
1584
+ case ' ': e.preventDefault(); activate(); break;
1585
+ case 'Tab': close(menu, btn); break;
1586
+ }
1275
1587
  });
1276
1588
  });
1277
1589
  if (!once(document.body, 'uiPopoverGlobal')) return;
@@ -1281,6 +1593,9 @@ export function initPopovers() {
1281
1593
  openPairs().forEach(({ btn, menu }) => {
1282
1594
  if (menu.contains(e.target) || btn.contains(e.target)) return;
1283
1595
  close(menu, btn);
1596
+ // focus moves into the rows now, so an outside press must not leave it on
1597
+ // a row the exit is about to hide
1598
+ if (menu.contains(document.activeElement)) btn.focus();
1284
1599
  });
1285
1600
  });
1286
1601
  document.addEventListener('keydown', e => {
@@ -1301,6 +1616,8 @@ export function initPopovers() {
1301
1616
  `table:page` {page}
1302
1617
  Every one of them bubbles from the `.table-wrap`. Nothing here needs to know
1303
1618
  what a row holds, which is why it can wire every table on every page. */
1619
+ const ROW_BOX = 'input[type="checkbox"][data-name]';
1620
+
1304
1621
  export function initTables() {
1305
1622
  const ariaSort = { asc: 'ascending', desc: 'descending' };
1306
1623
 
@@ -1326,7 +1643,7 @@ export function initTables() {
1326
1643
  });
1327
1644
 
1328
1645
  const selectAll = wrap.querySelector('input[type="checkbox"][data-select-all], thead input[type="checkbox"]');
1329
- const boxes = () => [...wrap.querySelectorAll('tbody input[type="checkbox"][data-name]')];
1646
+ const boxes = () => [...wrap.querySelectorAll(`tbody ${ROW_BOX}`)];
1330
1647
  const selected = () => boxes().filter(b => b.checked).map(b => b.dataset.name);
1331
1648
  const syncAll = () => {
1332
1649
  if (!selectAll) return;
@@ -1348,7 +1665,7 @@ export function initTables() {
1348
1665
  announce();
1349
1666
  });
1350
1667
  wrap.addEventListener('change', e => {
1351
- const b = e.target.closest?.('tbody input[type="checkbox"][data-name]');
1668
+ const b = e.target.closest?.(`tbody ${ROW_BOX}`);
1352
1669
  if (!b) return;
1353
1670
  b.closest('tr')?.classList.toggle('row-selected', b.checked);
1354
1671
  syncAll();
@@ -1357,8 +1674,8 @@ export function initTables() {
1357
1674
  // the row is the target, the same ruling the setting row runs on: a click
1358
1675
  // anywhere on a selectable row that missed a control drives its own box
1359
1676
  wrap.addEventListener('click', e => {
1360
- if (e.target.closest?.('button, a, input, select, textarea, label')) return;
1361
- e.target.closest('tr')?.querySelector('input[type="checkbox"][data-name]')?.click();
1677
+ if (isControl(e.target)) return;
1678
+ e.target.closest('tr')?.querySelector(ROW_BOX)?.click();
1362
1679
  });
1363
1680
 
1364
1681
  // a re-rendered body arrives with its own checked state: re-derive rather
@@ -1438,7 +1755,7 @@ const TYPE_CHAR = 80, TYPE_ERASE = 50, TYPE_HOLD = 5000, TYPE_GAP = 400, TYPE_ST
1438
1755
  export function initTypedPlaceholder() {
1439
1756
  // ambient motion is off under reduced motion, and this one has no reduced form:
1440
1757
  // a typed line with the typing removed is just a placeholder the field already has
1441
- if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1758
+ if (reducedMotion()) return;
1442
1759
 
1443
1760
  document.querySelectorAll('input[data-typed], textarea[data-typed]').forEach(input => {
1444
1761
  if (!once(input, 'uiTyped')) return;
@@ -1451,10 +1768,7 @@ export function initTypedPlaceholder() {
1451
1768
  }
1452
1769
  if (!Array.isArray(examples) || !examples.length) return;
1453
1770
 
1454
- const wrap = document.createElement('div');
1455
- wrap.className = 'typed-wrap';
1456
- input.parentNode.insertBefore(wrap, input);
1457
- wrap.appendChild(input);
1771
+ const wrap = ownWrap(input, 'typed-wrap');
1458
1772
  // a marker this module owns, aria-hidden like the inks: the field's own
1459
1773
  // label names it, so the typed examples need no second voice
1460
1774
  const overlay = ownChild(wrap, 'typed-overlay');
@@ -1521,9 +1835,21 @@ export function initTypedPlaceholder() {
1521
1835
  consumer opts in: this one selector names every component the stylesheet makes
1522
1836
  scroll, and the controller writes the cue class onto each. The axis is derived
1523
1837
  from what the element actually scrolls, never from a class the author wrote.
1524
- `no-scroll-fade` on any scroller is the one opt-out. Off at rest, so a no-JS
1838
+ A consumer scroller opts in with `data-scroll-fade`; `no-scroll-fade` on any
1839
+ scroller is the one opt-out. Floating panels never carry it: the select menu
1840
+ and the palette scroll bare. Off at rest, so a no-JS
1525
1841
  page paints no cue. The two has-more classes are the CSS's own gate. */
1526
- const SCROLL_FADE_SELECTOR = 'pre, .table-wrap, .chart-scroll, .modal-body, .log-view, .pagination-pages, .sidebar-nav, .navbar-nav, .select-menu, .palette-list';
1842
+ const SCROLL_FADE_SELECTOR = 'pre, .table-wrap, .chart-scroll, .modal-body, .log-view, .pagination-pages, .sidebar-nav, .navbar-nav, [data-scroll-fade]';
1843
+
1844
+ /* One resize listener and one observer serve every scroller on the page. A
1845
+ consumer that swaps markup (an htmx partial on every tick) replaces a
1846
+ scroller each time, and a per-element listener plus observation would hold
1847
+ every replaced node in memory for the page's life. */
1848
+ const fadeSyncs = new Map();
1849
+ const fadeObserver = new ResizeObserver(entries => {
1850
+ for (const entry of entries) fadeSyncs.get(entry.target)?.();
1851
+ });
1852
+ let fadeResizeBound = false;
1527
1853
 
1528
1854
  export function initScrollFade() {
1529
1855
  document.querySelectorAll(SCROLL_FADE_SELECTOR).forEach(el => {
@@ -1538,14 +1864,26 @@ export function initScrollFade() {
1538
1864
  el.classList.toggle('has-more-start', pos > 1);
1539
1865
  el.classList.toggle('has-more-end', pos + view < full - 1);
1540
1866
  };
1867
+ fadeSyncs.set(el, sync);
1541
1868
  el.addEventListener('scroll', sync, { passive: true });
1542
- addEventListener('resize', sync);
1543
1869
  /* The box changes size with no scroll and no window resize: the rail collapse
1544
1870
  takes this nav's clientHeight 756 to 722, which left the cue saying nothing
1545
1871
  was below with 34px still there, until the next scroll. */
1546
- new ResizeObserver(sync).observe(el);
1872
+ fadeObserver.observe(el);
1547
1873
  sync();
1548
1874
  });
1875
+ if (!fadeResizeBound) {
1876
+ addEventListener('resize', () => {
1877
+ for (const [el, sync] of fadeSyncs) if (el.isConnected) sync();
1878
+ });
1879
+ fadeResizeBound = true;
1880
+ }
1881
+ for (const [el, sync] of fadeSyncs) {
1882
+ if (el.isConnected) continue;
1883
+ el.removeEventListener('scroll', sync);
1884
+ fadeObserver.unobserve(el);
1885
+ fadeSyncs.delete(el);
1886
+ }
1549
1887
  }
1550
1888
 
1551
1889
  /* ============================ scrollbar =========================== */
@@ -1560,12 +1898,14 @@ export function initScrollFade() {
1560
1898
  positioned wrapper around every scrolling component in the system.
1561
1899
 
1562
1900
  It appears on any pointer move inside a scroller and on a scroll, and retires
1563
- `SCROLLBAR_IDLE` after both stop, her call 2026-09-02. Two things hold it past
1901
+ `SCROLLBAR_IDLE` after both stop. Two things hold it past
1564
1902
  that: a drag, and the pointer resting on the thumb itself, or the bar would
1565
1903
  retire out from under a pointer on its way to grab it. */
1566
1904
  const SCROLLBAR_IDLE = 900;
1567
1905
  const SCROLLBAR_MIN = 28; // a thumb shorter than this stops reading as a handle
1568
1906
  const SCROLLBAR_GAP = 2; // the scroller's own edge to the thumb's outer edge
1907
+ const SCROLLBAR_LANE = 8; // and on to its inner edge: the 6px thumb plus that gap.
1908
+ // The lane is where the bar is placed, hit-tested and held
1569
1909
  const SCROLLBAR_HOLD = 10; // slop around a 6px thumb, so the hold is reachable
1570
1910
  const SCROLLBAR_Z = 999; // the bar's own slot in the stacking ladder
1571
1911
  const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
@@ -1575,11 +1915,10 @@ const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
1575
1915
  export function initScrollbars() {
1576
1916
  if (!once(document.documentElement, 'uiScrollbars')) return;
1577
1917
  const root = document.scrollingElement || document.documentElement;
1578
- const bars = { y: document.createElement('div'), x: document.createElement('div') };
1579
- bars.y.className = 'ui-scrollbar ui-scrollbar-y';
1580
- bars.x.className = 'ui-scrollbar ui-scrollbar-x';
1581
- for (const b of Object.values(bars)) b.setAttribute('aria-hidden', 'true');
1582
- document.body.append(bars.y, bars.x);
1918
+ const bars = {
1919
+ y: ownChild(document.body, 'ui-scrollbar ui-scrollbar-y'),
1920
+ x: ownChild(document.body, 'ui-scrollbar ui-scrollbar-x'),
1921
+ };
1583
1922
  // the stylesheet hides the platform's bars only once these two exist
1584
1923
  document.documentElement.classList.add('has-drawn-scrollbars');
1585
1924
 
@@ -1590,15 +1929,23 @@ export function initScrollbars() {
1590
1929
  const at = (el, axis) => axis === 'y' ? el.scrollTop : el.scrollLeft;
1591
1930
 
1592
1931
  const scrolls = (el, axis) => {
1932
+ /* `no-scrollbar` opts a scroller out of the drawn bar: it keeps scrolling,
1933
+ and the bar keeps riding the page or column it belongs to. A surface
1934
+ that reads as fixed chrome carries it, so it never grows a bar of its
1935
+ own. */
1936
+ if (el.closest('.no-scrollbar')) return false;
1593
1937
  if (full(el, axis) <= view(el, axis) + 1) return false;
1594
1938
  if (el === root) return true;
1595
1939
  const ov = getComputedStyle(el)[axis === 'y' ? 'overflowY' : 'overflowX'];
1596
1940
  return ov === 'auto' || ov === 'scroll';
1597
1941
  };
1598
- // the INNERMOST scroller under a node, so a table inside a modal wins
1599
- const scrollerFrom = node => {
1942
+ /* The INNERMOST scroller ON THIS AXIS under a node, so a table inside a modal
1943
+ wins and a table that scrolls only x leaves the y bar with the page. Asking
1944
+ one question for both axes answered the wrong one: a wide table inside a
1945
+ tall page took BOTH bars, and the page's own y bar went with it. */
1946
+ const scrollerFrom = (node, axis) => {
1600
1947
  for (let el = node; el instanceof Element; el = el.parentElement) {
1601
- if (scrolls(el, 'y') || scrolls(el, 'x')) return el;
1948
+ if (scrolls(el, axis)) return el;
1602
1949
  }
1603
1950
  return root;
1604
1951
  };
@@ -1646,8 +1993,8 @@ export function initScrollbars() {
1646
1993
  };
1647
1994
 
1648
1995
  /* Chrome pinned over the scrollport hides the track under it, and a thumb
1649
- drawn there marks a position nobody can scroll to. Her call 2026-09-02, and
1650
- the covers are DERIVED rather than named: every visible fixed or sticky box
1996
+ drawn there marks a position nobody can scroll to. The covers are DERIVED
1997
+ rather than named: every visible fixed or sticky box
1651
1998
  is a candidate, and one counts against this bar only where it overlaps the
1652
1999
  bar's own lane and sits below the bar in the stacking ladder.
1653
2000
 
@@ -1710,8 +2057,8 @@ export function initScrollbars() {
1710
2057
 
1711
2058
  const cover = (el, axis) => {
1712
2059
  const box = boxOf(el);
1713
- const lane = axis === 'y' ? [box.right - 8, box.right - SCROLLBAR_GAP]
1714
- : [box.bottom - 8, box.bottom - SCROLLBAR_GAP];
2060
+ const lane = axis === 'y' ? [box.right - SCROLLBAR_LANE, box.right - SCROLLBAR_GAP]
2061
+ : [box.bottom - SCROLLBAR_LANE, box.bottom - SCROLLBAR_GAP];
1715
2062
  const spans = [];
1716
2063
  /* `checkVisibility` is guarded because this runs inside an rAF callback, and a
1717
2064
  TypeError there reaches the ring's `window.onerror` bail, which drops the
@@ -1770,37 +2117,67 @@ export function initScrollbars() {
1770
2117
  return { padA, track, len, room, max: full(el, axis) - v };
1771
2118
  };
1772
2119
 
1773
- let active = root;
2120
+ /* ONE OWNER PER AXIS. A page scroll used to hand both bars to the document, so
2121
+ a wide table's x bar lost its scroller in the middle of a wheel: `place`
2122
+ returns before it repositions, and the bar then fades out over four frames
2123
+ wherever it last painted. Measured on the reference sheet, one 240px wheel
2124
+ with the pointer inside the table: the bar held top 545.1 while the table's
2125
+ bottom went 553.1 to 313.1, so it sat 240px low, outside the card, over the
2126
+ section under it. Per axis it keeps its owner and tracks the table instead. */
2127
+ const active = { x: root, y: root };
1774
2128
  let hideAt = 0;
1775
2129
  let drag = null;
1776
2130
  let frame = 0;
1777
2131
  let pointer = null;
2132
+ // the thumb's last painted state per scroller and axis, for place()'s re-anchor
2133
+ let anchors = new WeakMap();
2134
+ const anchor = (el, axis) => {
2135
+ let c = anchors.get(el);
2136
+ if (!c) anchors.set(el, c = {});
2137
+ return c[axis] || (c[axis] = {});
2138
+ };
1778
2139
 
1779
2140
  const place = axis => {
1780
2141
  const bar = bars[axis];
1781
- const el = drag ? drag.el : active;
2142
+ const el = drag && drag.axis === axis ? drag.el : active[axis];
1782
2143
  if (!el.isConnected || !scrolls(el, axis)) { bar.classList.remove('is-on'); return; }
1783
2144
  const box = boxOf(el);
1784
2145
  const { padA, track, len, room, max } = metrics(el, axis);
1785
2146
  // pinned chrome can take the whole scrollport, and a track with no room for
1786
2147
  // a handle is a mark, not a bar
1787
2148
  if (room < SCROLLBAR_MIN) { bar.classList.remove('is-on'); return; }
1788
- const run = padA + (max > 0 ? (at(el, axis) / max) * (track - len) : 0);
2149
+ /* A cover that moves under a live scroll (a stuck header at its section's
2150
+ end) shrinks `padA` one-to-one with the scroll while the progress term
2151
+ follows only at (track - len) / max, so the thumb runs backward under a
2152
+ forward scroll. Re-anchoring the line at the last painted position keeps
2153
+ the thumb continuous through the change and still reaches the track's end
2154
+ exactly at full scroll. With static chrome the re-anchored line IS the
2155
+ standard one, so nothing else changes. */
2156
+ const s = at(el, axis);
2157
+ const a = anchor(el, axis);
2158
+ let run;
2159
+ if (max > 0 && track - len >= 1 && a.at < max) {
2160
+ run = a.run + (s - a.at) * (padA + track - len - a.run) / (max - a.at);
2161
+ } else {
2162
+ run = padA + (max > 0 ? (s / max) * (track - len) : 0);
2163
+ }
2164
+ a.at = s;
2165
+ a.run = run;
1789
2166
  if (axis === 'y') {
1790
2167
  bar.style.top = `${box.top + run}px`;
1791
- bar.style.left = `${box.right - 8}px`;
2168
+ bar.style.left = `${box.right - SCROLLBAR_LANE}px`;
1792
2169
  bar.style.height = `${len}px`;
1793
2170
  } else {
1794
2171
  bar.style.left = `${box.left + run}px`;
1795
- bar.style.top = `${box.bottom - 8}px`;
2172
+ bar.style.top = `${box.bottom - SCROLLBAR_LANE}px`;
1796
2173
  bar.style.width = `${len}px`;
1797
2174
  }
1798
2175
  // the thumb's own box, padded: a bar that retired under a pointer reaching
1799
2176
  // for it would take the grab with it
1800
2177
  const near = pointer && (axis === 'y'
1801
- ? pointer.x >= box.right - 8 - SCROLLBAR_HOLD && pointer.x <= box.right - SCROLLBAR_GAP + SCROLLBAR_HOLD
2178
+ ? pointer.x >= box.right - SCROLLBAR_LANE - SCROLLBAR_HOLD && pointer.x <= box.right - SCROLLBAR_GAP + SCROLLBAR_HOLD
1802
2179
  && pointer.y >= box.top + run - SCROLLBAR_HOLD && pointer.y <= box.top + run + len + SCROLLBAR_HOLD
1803
- : pointer.y >= box.bottom - 8 - SCROLLBAR_HOLD && pointer.y <= box.bottom - SCROLLBAR_GAP + SCROLLBAR_HOLD
2180
+ : pointer.y >= box.bottom - SCROLLBAR_LANE - SCROLLBAR_HOLD && pointer.y <= box.bottom - SCROLLBAR_GAP + SCROLLBAR_HOLD
1804
2181
  && pointer.x >= box.left + run - SCROLLBAR_HOLD && pointer.x <= box.left + run + len + SCROLLBAR_HOLD);
1805
2182
  const on = drag ? drag.axis === axis : Boolean(near) || performance.now() < hideAt;
1806
2183
  const wasOn = bar.classList.contains('is-on');
@@ -1836,13 +2213,18 @@ export function initScrollbars() {
1836
2213
  if (drag || performance.now() < hideAt) sync();
1837
2214
  };
1838
2215
  const sync = () => { frame ||= requestAnimationFrame(tick); };
2216
+ // every route that shows the bar restarts its retirement from now
2217
+ const arm = () => { hideAt = performance.now() + SCROLLBAR_IDLE; sync(); };
1839
2218
 
1840
2219
  addEventListener('scroll', e => {
1841
2220
  const el = e.target === document || e.target === root ? root : e.target;
1842
2221
  if (!(el instanceof Element) && el !== root) return;
1843
- if (!drag) active = el;
1844
- hideAt = performance.now() + SCROLLBAR_IDLE;
1845
- sync();
2222
+ // a bare scroller never claims the bar, nor arms it for the page behind it
2223
+ if (el.closest('.no-scrollbar')) return;
2224
+ // the event names the element that scrolled and never the axis, so it takes
2225
+ // the axes it can actually drive and leaves the other bar with its owner
2226
+ if (!drag) for (const axis of ['x', 'y']) if (scrolls(el, axis)) active[axis] = el;
2227
+ arm();
1846
2228
  }, { capture: true, passive: true });
1847
2229
 
1848
2230
  addEventListener('pointermove', e => {
@@ -1857,12 +2239,11 @@ export function initScrollbars() {
1857
2239
  `active` by construction, so leave it alone. */
1858
2240
  const onBar = e.target instanceof Element && e.target.classList.contains('ui-scrollbar');
1859
2241
  if (!drag && !onBar) {
1860
- const el = scrollerFrom(e.target);
1861
- if (el !== active) active = el;
2242
+ active.x = scrollerFrom(e.target, 'x');
2243
+ active.y = scrollerFrom(e.target, 'y');
1862
2244
  }
1863
2245
  // the move is what arms the timer: a resting pointer retires the bar
1864
- hideAt = performance.now() + SCROLLBAR_IDLE;
1865
- sync();
2246
+ arm();
1866
2247
  }, { passive: true });
1867
2248
 
1868
2249
  /* `pointer` was write-only, and the Y bar's hold band runs to `innerWidth + 8`,
@@ -1872,11 +2253,11 @@ export function initScrollbars() {
1872
2253
  for the same reason. */
1873
2254
  document.documentElement.addEventListener('pointerleave', () => { pointer = null; sync(); });
1874
2255
 
1875
- addEventListener('resize', () => { radii = new WeakMap(); sync(); });
2256
+ addEventListener('resize', () => { radii = new WeakMap(); anchors = new WeakMap(); sync(); });
1876
2257
 
1877
2258
  for (const axis of ['y', 'x']) {
1878
2259
  bars[axis].addEventListener('pointerdown', e => {
1879
- const el = active;
2260
+ const el = active[axis];
1880
2261
  if (!scrolls(el, axis)) return;
1881
2262
  if (e.pointerType === 'mouse' && e.button !== 0) return; // middle and right are not a drag
1882
2263
  if (drag) return; // one bar at a time: a second press would
@@ -1903,15 +2284,13 @@ export function initScrollbars() {
1903
2284
  const now = axis === 'y' ? e.clientY : e.clientX;
1904
2285
  const to = drag.from + (now - drag.origin) * drag.rate;
1905
2286
  if (axis === 'y') drag.el.scrollTop = to; else drag.el.scrollLeft = to;
1906
- hideAt = performance.now() + SCROLLBAR_IDLE;
1907
- sync();
2287
+ arm();
1908
2288
  });
1909
2289
  const end = () => {
1910
2290
  if (!drag || drag.axis !== axis) return;
1911
2291
  drag = null;
1912
2292
  bars[axis].classList.remove('is-drag');
1913
- hideAt = performance.now() + SCROLLBAR_IDLE;
1914
- sync();
2293
+ arm();
1915
2294
  };
1916
2295
  bars[axis].addEventListener('pointerup', end);
1917
2296
  bars[axis].addEventListener('pointercancel', end);
@@ -1959,6 +2338,120 @@ export function initDropzone() {
1959
2338
  });
1960
2339
  }
1961
2340
 
2341
+ /* ============================== stepper =========================== */
2342
+
2343
+ /* Both bounds are read off the value, so an authored `disabled` is overwritten on
2344
+ the first sync: the state belongs to the component. A NaN loses both comparisons,
2345
+ which is what carries an empty field and an undeclared bound: neither can be at a
2346
+ floor or a ceiling, so neither button goes dead. */
2347
+ const syncParts = ({ input, minus, plus }) => {
2348
+ const n = input.valueAsNumber;
2349
+ minus.disabled = n <= parseFloat(input.min);
2350
+ plus.disabled = n >= parseFloat(input.max);
2351
+ };
2352
+
2353
+ /* The parts each wired wrap's listeners actually hold, so the reset writes bounds
2354
+ onto the same buttons a press does: a fresh lookup diverges the moment a wrap
2355
+ grows a third `.input-step`, and the button the user clicks then stays live at
2356
+ its own ceiling while an inert one goes dead. Weak, so a wrap a page drops is
2357
+ collectable rather than held by this file, which is what the per-form listener
2358
+ this replaced got wrong. Membership is also the only honest answer to "did THIS
2359
+ controller wire it": `once`'s mark is an attribute, and an attribute survives the
2360
+ clone that a repeated form row is built from, listeners and all left behind. */
2361
+ const wiredSteppers = new WeakMap();
2362
+
2363
+ /* The number stepper's two routes into one value: the buttons and the keyboard.
2364
+ `stepUp` and `stepDown` ARE the bounded move, so nothing here restates the grid
2365
+ snap, the clamp or the number formatting. They throw where the field declares no
2366
+ allowed value step, and a throw inside a listener reaches the cursor's `error`
2367
+ handler and takes the page's ring down with it, so a field that cannot step is
2368
+ left unwired rather than caught on every press. */
2369
+ const STEPPER = '[data-stepper]';
2370
+
2371
+ export function initStepper() {
2372
+ /* A form reset restores its fields with no event on any of them, so the bounds
2373
+ would stay where the old values left them and a `+` could sit dead over a
2374
+ mid-range number for the life of the page. `reset` bubbles, and the restore
2375
+ lands after it, on the NEXT TASK rather than on a microtask: measured on one
2376
+ instrumented handler, a trusted press reads the old value at the first and
2377
+ second microtask tick and the restored one at a timeout and a frame, while a
2378
+ scripted `.click()` reads it restored at the first tick. So the activation a
2379
+ person performs is exactly the one a microtask is too early for, and a probe
2380
+ that drives the button from inside the page cannot see it.
2381
+
2382
+ ONE page-level listener, never one per stepper on its own form. A listener on a
2383
+ node outside the once() mark outlives the markup it was wired for: measured, a
2384
+ wrap replaced inside a persisting form left the detached generation still being
2385
+ synced. A reset is rare and user-driven, so the sweep asks no question about
2386
+ which form owns which field, which is also what carries a field associated by
2387
+ `form=""` from outside the form's own subtree. What it does ask is whether this
2388
+ controller wired the wrap, so a stepper it refused cannot take a bound costume
2389
+ from a reset elsewhere on the page and still answer no press. */
2390
+ if (once(document.body, 'uiStepperReset')) {
2391
+ document.addEventListener('reset', () => setTimeout(() =>
2392
+ document.querySelectorAll(STEPPER).forEach(wrap => {
2393
+ const parts = wiredSteppers.get(wrap);
2394
+ if (parts) syncParts(parts);
2395
+ }), 0));
2396
+ }
2397
+
2398
+ // the field and its two controls, from the wrap alone: a `.input-step` before the
2399
+ // field decrements and one after it increments
2400
+ document.querySelectorAll(STEPPER).forEach(wrap => {
2401
+ const kids = [...wrap.children];
2402
+ const input = kids.find(el => el.matches('input[type="number"]'));
2403
+ if (!input) return;
2404
+ const at = kids.indexOf(input);
2405
+ const minus = kids.slice(0, at).findLast(el => el.matches('.input-step'));
2406
+ const plus = kids.slice(at + 1).find(el => el.matches('.input-step'));
2407
+ if (!minus || !plus) return;
2408
+ if (input.step.trim().toLowerCase() === 'any' || !once(wrap, 'uiStepper')) return;
2409
+ const parts = { input, minus, plus };
2410
+ wiredSteppers.set(wrap, parts);
2411
+ const sync = () => syncParts(parts);
2412
+
2413
+ // a programmatic value write fires neither event, so a consumer would hear a
2414
+ // press and a keystroke differently
2415
+ const notify = () => input.dispatchEvent(new Event('input', { bubbles: true }));
2416
+ const commit = () => {
2417
+ notify();
2418
+ input.dispatchEvent(new Event('change', { bubbles: true }));
2419
+ };
2420
+
2421
+ const press = (btn, move) => btn.addEventListener('click', () => {
2422
+ if (input.disabled) return; // the group's :has() rule refuses the pointer, never the keyboard
2423
+ const before = input.value;
2424
+ move();
2425
+ sync();
2426
+ if (input.value !== before) commit();
2427
+ });
2428
+ press(minus, () => input.stepDown());
2429
+ press(plus, () => input.stepUp());
2430
+
2431
+ /* Typing is the route the platform leaves open: a number field holds 99 under
2432
+ max="12" and only reports itself invalid. `min` is the one floor the markup
2433
+ declares, so an empty or unparseable field falls back to it, and to nothing
2434
+ where no min is declared rather than to a zero this file would be inventing.
2435
+
2436
+ This one only NOTIFIES: the commit it corrects is the change still dispatching
2437
+ around it, and that one carries the clamped value to every listener after this
2438
+ file's, because the write above lands before they run. A second dispatch here
2439
+ would run the whole listener list a second time nested, and the outer dispatch
2440
+ would then resume into the same consumer, so one keystroke would commit twice. */
2441
+ input.addEventListener('change', () => {
2442
+ const before = input.value;
2443
+ const n = input.valueAsNumber;
2444
+ if (Number.isNaN(n)) { if (input.min !== '') input.value = input.min; }
2445
+ else if (n < parseFloat(input.min)) input.value = input.min;
2446
+ else if (n > parseFloat(input.max)) input.value = input.max;
2447
+ sync();
2448
+ if (input.value !== before) notify();
2449
+ });
2450
+
2451
+ sync();
2452
+ });
2453
+ }
2454
+
1962
2455
  /* ============================== boot ============================== */
1963
2456
 
1964
2457
  export function init() {
@@ -1970,6 +2463,7 @@ export function init() {
1970
2463
  initToggles();
1971
2464
  initDisclosures();
1972
2465
  initSelect();
2466
+ initSlider();
1973
2467
  initPalette();
1974
2468
  initTooltip();
1975
2469
  initPopovers();
@@ -1986,6 +2480,7 @@ export function init() {
1986
2480
  initScrollFade();
1987
2481
  initScrollbars();
1988
2482
  initDropzone();
2483
+ initStepper();
1989
2484
  }
1990
2485
 
1991
2486
  // a module script is deferred, so the document is parsed by the time this runs