reviewzy 0.2.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.
@@ -0,0 +1,2487 @@
1
+ /* controllers. Every component that needs script, in one module.
2
+ Load as `<script type="module" src="ui.js"></script>`. It wires itself on load
3
+ against whatever markup is present and does nothing where a component is absent,
4
+ so one script tag is correct for every page in the system.
5
+
6
+ Contract, and the reason this file exists rather than a set of paste-in blocks:
7
+ - NOTHING here throws on a page that lacks a component. The pasted controllers
8
+ this replaces crashed on a null `#navbar-nav`, a null `#modal-btn` and three
9
+ null table ids, and the cursor's `window.onerror` handler reads any thrown
10
+ error as a bail: one missing modal took the page's ring down with it.
11
+ - `init()` is idempotent. Every element it wires carries a `data-ui-*` mark, so
12
+ calling it again after rendering new markup wires only what is new. The one
13
+ step outside that is the header label, which re-runs on every call by design
14
+ (a table may re-render its head) and is idempotent because the span it builds
15
+ is the thing it looks for.
16
+ - The data half of a table is the page's. This file owns sort cycling, selection
17
+ sync, paging clicks and the filter menu, and announces each as an event.
18
+
19
+ Named exports let a page wire one piece by hand; the auto-init at the bottom is
20
+ the default and covers every page in this system. */
21
+
22
+ import { collectPropSelectors } from './cursor-rules.js';
23
+
24
+ /* ============================ helpers ============================ */
25
+
26
+ // a component is wired once, whatever calls init(). The mark is on the element the
27
+ // listener is attached to, never on an ancestor, or a re-render silently skips it.
28
+ const once = (el, key) => {
29
+ if (!el || el.dataset[key]) return false;
30
+ el.dataset[key] = '1';
31
+ return true;
32
+ };
33
+
34
+ // an exit costume's own clock, read off the element, so a token change or reduced
35
+ // motion retimes every hide without a number in this file
36
+ const animMs = el => (parseFloat(getComputedStyle(el).animationDuration) || 0) * 1000 + 20;
37
+ const transMs = el => Math.max(...getComputedStyle(el).transitionDuration.split(',').map(parseFloat)) * 1000;
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
+
83
+ /* ====================== sliding inks (tabs, navbar) ====================== */
84
+
85
+ /* First placement lands in place: with the transform transition live, an ink
86
+ fading in would slide from x=0 behind its own fade, reading as a teleport.
87
+ Kill the transition once, flush, restore.
88
+
89
+ An ink declaring --ink-base (.tab-ink, .navbar-ink) is laid out at that width
90
+ and scaled, so its length rides the same transform as its travel and cannot
91
+ snap to the target while the bar is still moving. The boxed pill has no
92
+ --ink-base: scaleX would distort its 11px corners, so it resizes instead.
93
+
94
+ Both ride translate3d: the z holds the ink's layer for the life of the page,
95
+ so the sub-pixel geometry painted during the slide is the same geometry
96
+ painted after it. A layer dropped at transitionend re-rasterizes the
97
+ fractional size and position at whole pixels, which is the visible snap
98
+ after landing. */
99
+ export function placeInk(inkEl, width, x) {
100
+ const first = !inkEl.dataset.placed;
101
+ if (first) {
102
+ inkEl.dataset.placed = '1';
103
+ inkEl.style.transition = 'none';
104
+ }
105
+ const base = parseFloat(getComputedStyle(inkEl).getPropertyValue('--ink-base'));
106
+ const next = base > 0
107
+ ? `translate3d(${x}px, 0, 0) scaleX(${width / base})`
108
+ : `translate3d(${x}px, 0, 0)`;
109
+ if (!(base > 0)) inkEl.style.width = width + 'px';
110
+ inkEl.style.transform = next;
111
+ if (first) {
112
+ inkEl.getBoundingClientRect(); // flush: the jump happens un-animated
113
+ requestAnimationFrame(() => { inkEl.style.transition = ''; });
114
+ }
115
+ inkEl.style.opacity = '1';
116
+ }
117
+
118
+ // one geometry read for every ink on the page: the active child's box against its rail
119
+ function placeFrom(wrapEl, inkEl, activeSel) {
120
+ if (!wrapEl || !inkEl) return;
121
+ const active = wrapEl.querySelector(activeSel);
122
+ if (!active) { inkEl.style.opacity = '0'; return; }
123
+ const wRect = wrapEl.getBoundingClientRect();
124
+ const aRect = active.getBoundingClientRect();
125
+ placeInk(inkEl, aRect.width, aRect.left - wRect.left);
126
+ }
127
+
128
+ /* A MARKER A CONSUMER HAS TO WRITE IS THE COMPONENT DEFINED TWICE, and the copy
129
+ that gets forgotten is the one a reader meets. Four of them used to be markup:
130
+ `.tab-ink`, `.tabs-boxed-ink`, `.navbar-ink` and the sidebar row's `.nav-dot`,
131
+ 61 hand-written spans on the pages in this repo alone. They are built here, so
132
+ a page writes the wrap and gets the whole component. Where a marker sits is
133
+ part of it: these are positioned with no z-index, so paint order is document
134
+ order, and one that paints UNDER its siblings goes first. Existing markup is
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);
142
+ if (!el) {
143
+ el = svg ? document.createElementNS(SVG_NS, tag) : document.createElement(tag);
144
+ el.setAttribute('class', cls);
145
+ el.setAttribute('aria-hidden', 'true');
146
+ if (first) parent.prepend(el); else parent.append(el);
147
+ }
148
+ return el;
149
+ }
150
+
151
+ /* Same reading, one step further: a marker is not the only internal a consumer can
152
+ be left to write. A table header's label needs a box of its own to centre in,
153
+ because a cell centres its LINE BOX and all-caps ink rides high in one. The
154
+ measurement and the levers that do not work are in `components.css` beside
155
+ `.th-label`. This one carries the column's accessible name, so unlike `ownChild`
156
+ it is never `aria-hidden`. It wraps the cell's text into one span placed where
157
+ the text starts, which is why it REFUSES a cell whose text is split by an
158
+ element: joining `P50 <span class=tag>ms</span> avg` would move the tag to the
159
+ end and read the column out as `P50 avg ms`. Nothing on disk writes that shape,
160
+ and a design system is consumed by markup nobody here wrote. The shapes it does
161
+ take are a sort button (text, then the glyph) and a plain cell; the select-all
162
+ cell holds a checkbox and no text, so it is left alone. */
163
+ function ownLabel(parent, cls) {
164
+ const have = parent.querySelector(`:scope > .${cls}`);
165
+ if (have) return have;
166
+ const kids = [...parent.childNodes];
167
+ const texts = kids.filter(n => n.nodeType === 3 && n.nodeValue.trim());
168
+ if (!texts.length) return null;
169
+ const span = kids.slice(kids.indexOf(texts[0]), kids.indexOf(texts[texts.length - 1]) + 1);
170
+ if (span.some(n => n.nodeType === 1)) return null; // an element splits the text
171
+ const el = document.createElement('span');
172
+ el.className = cls;
173
+ el.textContent = texts.map(n => n.nodeValue).join('').trim();
174
+ texts.slice(1).forEach(n => n.remove());
175
+ parent.replaceChild(el, texts[0]);
176
+ return el;
177
+ }
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
+
190
+ // a resize or a scroll moves the ink without animating the slide: the indicator is
191
+ // following its tab, not travelling to a new one
192
+ function replace(inkEl, run) {
193
+ if (!inkEl) return;
194
+ inkEl.style.transition = 'none';
195
+ run();
196
+ requestAnimationFrame(() => { inkEl.style.transition = ''; });
197
+ }
198
+
199
+ /* ============================== tabs ============================== */
200
+
201
+ /* Underline tabs with a sliding ink, and boxed segments with a sliding pill.
202
+
203
+ `data-tabs="group-id"` groups tabs that live in different wraps and `data-panel`
204
+ names each one's panel. BOTH ARE OPTIONAL: with neither, the wrap is the group
205
+ and the control just moves its own marker, which is what a segmented control
206
+ with no panels needs. Keying only on `data-tabs` is why every page carrying a
207
+ panel-less boxed control had a hand-written copy of this. */
208
+ const TAB_WRAP = '.tabs-wrap, .tabs-boxed-wrap';
209
+
210
+ export function initTabs() {
211
+ /* examples/dashboard.html is what this cost: its wrap held no pill, so the
212
+ segments cross-faded their own backgrounds instead. The two read identical
213
+ AT REST, which is why no screenshot could fail on it; measured 60ms into a
214
+ click, the sheet's pill at translate 24.55 on its way to 42.42 against the
215
+ dashboard's nothing. The boxed pill paints under its segments, so it goes
216
+ first. */
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);
233
+ const placeWrap = wrap => {
234
+ const ink = inkFor(wrap);
235
+ if (!ink) return;
236
+ const boxed = ink.classList.contains('tabs-boxed-ink');
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');
239
+ if (boxed) wrap.classList.add('has-ink');
240
+ };
241
+
242
+ document.querySelectorAll('.tab, .tab-box').forEach(btn => {
243
+ if (!once(btn, 'uiTab')) return;
244
+ const wrap = btn.closest(TAB_WRAP);
245
+ const kind = btn.classList.contains('tab-box') ? '.tab-box' : '.tab';
246
+ btn.addEventListener('click', () => {
247
+ const group = btn.dataset.tabs;
248
+ const peers = group
249
+ ? [...document.querySelectorAll(`[data-tabs="${group}"]`)]
250
+ : [...(wrap ? wrap.querySelectorAll(kind) : [btn])];
251
+ peers.forEach(b => b.classList.remove('active'));
252
+ btn.classList.add('active');
253
+
254
+ new Set(peers.map(b => b.dataset.panel)).forEach(id => {
255
+ const el = id && document.getElementById(id);
256
+ if (el) el.classList.remove('active');
257
+ });
258
+ const panel = btn.dataset.panel && document.getElementById(btn.dataset.panel);
259
+ if (panel) panel.classList.add('active');
260
+
261
+ // a data-tabs group can span wraps, so every wrap holding a peer re-places
262
+ new Set(peers.map(b => b.closest(TAB_WRAP)).filter(Boolean))
263
+ .forEach(placeWrap);
264
+ });
265
+ });
266
+
267
+ requestAnimationFrame(() => wraps().forEach(placeWrap)); // first placement, after layout
268
+ if (once(document.body, 'uiTabsResize')) {
269
+ // a resize moves the ink without animating the slide: it is following its tab,
270
+ // not travelling to a new one
271
+ addEventListener('resize', () => wraps().forEach(w => replace(inkFor(w), () => placeWrap(w))));
272
+ }
273
+ }
274
+
275
+ /* ============================= navbar ============================= */
276
+
277
+ /* The tab ink's physics on the horizontal navbar, plus the bar's docking state.
278
+
279
+ Every `.navbar-nav` holding a `.navbar-ink` is wired, and each one owns only its
280
+ own links: a page carrying a second navbar (a narrow variant, a docs sub-bar)
281
+ used to need a hand-written copy of this controller, and the copy drifted.
282
+ Nothing here is required to exist; a page with no navbar wires nothing rather
283
+ than throwing on a null lookup, which is what the pasted version did. */
284
+ export function initNavbar() {
285
+ document.querySelectorAll('.navbar-nav').forEach(nav => {
286
+ if (!once(nav, 'uiNavbar')) return;
287
+ const ink = ownChild(nav, 'navbar-ink');
288
+ const place = () => placeFrom(nav, ink, '.navbar-link.active');
289
+
290
+ nav.querySelectorAll('.navbar-link').forEach(link => {
291
+ link.addEventListener('click', e => {
292
+ if (link.getAttribute('href') === '#') e.preventDefault();
293
+ nav.querySelectorAll('.navbar-link').forEach(l => l.classList.remove('active'));
294
+ link.classList.add('active');
295
+ place();
296
+ });
297
+ });
298
+
299
+ addEventListener('resize', () => replace(ink, place));
300
+ // under 768px the band scrolls and the ink rides the content: the same contract
301
+ nav.addEventListener('scroll', () => replace(ink, place), { passive: true });
302
+ requestAnimationFrame(place);
303
+ });
304
+
305
+ /* The docking state. scrollY is the whole state: `.is-top` drops the moment the
306
+ page moves and returns at the top, so the bar is a floating card at rest and a
307
+ band once scrolled. Below 768px the CSS ignores it. */
308
+ document.querySelectorAll('.navbar').forEach(bar => {
309
+ if (!once(bar, 'uiNavbarDock')) return;
310
+ const sync = () => bar.classList.toggle('is-top', scrollY <= 0);
311
+ sync();
312
+ addEventListener('scroll', sync, { passive: true });
313
+ });
314
+ }
315
+
316
+ /* ============================= sidebar ============================ */
317
+
318
+ /* The marker is the active row's own dot, so nothing is measured or positioned:
319
+ the controller moves one class and the CSS does the rest.
320
+
321
+ Every `.nav-item` is wired, each owning only the rows in its own sidebar.
322
+ Keying on `.nav-item[data-section]` left a plain sidebar inert, so the worked
323
+ dashboard example carried a hand-written copy of these six lines. */
324
+ export function initSidebar() {
325
+ document.querySelectorAll('.nav-item').forEach(item => {
326
+ if (!once(item, 'uiNav')) return;
327
+ // the row's own dot is the marker, so it belongs to the row, not to the page
328
+ ownChild(item, 'nav-dot', { tag: 'span', first: true });
329
+ const scope = item.closest('.sidebar') || document;
330
+ item.addEventListener('click', () => {
331
+ scope.querySelectorAll('.nav-item').forEach(n => {
332
+ n.classList.remove('active');
333
+ n.removeAttribute('aria-current');
334
+ });
335
+ item.classList.add('active');
336
+ item.setAttribute('aria-current', 'true');
337
+ });
338
+ });
339
+
340
+ // the button is optional, and its own sidebar is the one it folds
341
+ document.querySelectorAll('.sidebar-collapse').forEach(collapse => {
342
+ const column = collapse.closest('.sidebar');
343
+ if (!column || !once(collapse, 'uiCollapse')) return;
344
+ const sync = () => {
345
+ const folded = column.classList.contains('rail');
346
+ collapse.setAttribute('aria-expanded', String(!folded));
347
+ collapse.setAttribute('aria-label', folded ? 'Expand sidebar' : 'Collapse sidebar');
348
+ };
349
+ sync(); // a sidebar may ship folded
350
+ collapse.addEventListener('click', () => { column.classList.toggle('rail'); sync(); });
351
+ });
352
+ }
353
+
354
+ /* ========================== mobile drawer ========================= */
355
+
356
+ export function initDrawer() {
357
+ const sidebar = document.getElementById('sidebar');
358
+ const overlay = document.getElementById('sidebar-overlay');
359
+ const hamburger = document.getElementById('hamburger-btn');
360
+ if (!sidebar) return;
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'); };
367
+ const setOpen = open => {
368
+ sidebar.classList.toggle('open', open);
369
+ overlay?.classList.toggle('open', open);
370
+ hamburger?.setAttribute('aria-expanded', String(open));
371
+ const held = sidebar.contains(document.activeElement);
372
+ setInert();
373
+ if (!open && held) hamburger?.focus();
374
+ };
375
+ narrow.addEventListener('change', setInert);
376
+ setInert();
377
+ if (hamburger && once(hamburger, 'uiDrawer')) hamburger.addEventListener('click', () => setOpen(true));
378
+ if (overlay && once(overlay, 'uiDrawer')) overlay.addEventListener('click', () => setOpen(false));
379
+ if (once(sidebar, 'uiDrawerRows')) {
380
+ document.querySelectorAll('.nav-item, .navbar-link').forEach(item => {
381
+ item.addEventListener('click', () => { if (innerWidth <= 768) setOpen(false); });
382
+ });
383
+ addEventListener('keydown', e => { if (e.key === 'Escape' && sidebar.classList.contains('open')) setOpen(false); });
384
+ }
385
+ }
386
+
387
+ /* ====================== switches and their rows ====================== */
388
+
389
+ /* A `.toggle` is a real button carrying `aria-pressed`, so the state and the
390
+ semantics move together and never disagree.
391
+
392
+ THE ROW IS THE TARGET: a coarse pointer gets its 44px on
393
+ the row and the control keeps its proportions, so the row has to be clickable
394
+ or that floor buys nothing. The guard skips a click that landed on another
395
+ control, so the control's own handler runs once and the row cannot double-toggle
396
+ it. Every page carrying a settings panel had written this by hand. */
397
+ export function initToggles() {
398
+ document.querySelectorAll('.toggle').forEach(t => {
399
+ if (t.disabled || !once(t, 'uiToggle')) return;
400
+ t.addEventListener('click', () => {
401
+ t.setAttribute('aria-pressed', String(t.classList.toggle('on')));
402
+ });
403
+ });
404
+
405
+ document.querySelectorAll('.toggle-wrap, .setting-row').forEach(row => {
406
+ if (!once(row, 'uiRowTarget')) return;
407
+ row.addEventListener('click', e => {
408
+ if (isControl(e.target)) return;
409
+ row.querySelector('.toggle:not(:disabled)')?.click();
410
+ });
411
+ });
412
+ }
413
+
414
+ /* ============================ disclosure ========================== */
415
+
416
+ /* `details` has no disabled state, so a row that cannot act says so with
417
+ aria-disabled and the summary stays focusable to announce why. Its click IS
418
+ the toggle (keyboard Enter and Space ride the same event), so cancelling the
419
+ click cancels the row; the costume lives in components.css beside the
420
+ accordion. */
421
+ export function initDisclosures() {
422
+ document.querySelectorAll('.disclosure[aria-disabled="true"] > summary').forEach(s => {
423
+ if (!once(s, 'uiDisclosure')) return;
424
+ s.addEventListener('click', e => e.preventDefault());
425
+ });
426
+ }
427
+
428
+ /* ============================== select ============================ */
429
+
430
+ /* The trigger keeps DOM focus for the whole interaction and the panel is a listbox,
431
+ so the arrow-key row is named by aria-activedescendant instead of being focused.
432
+ Escape therefore always lands back on the control that opened. */
433
+ const selectControllers = [];
434
+
435
+ export function initSelect() {
436
+ document.querySelectorAll('.select').forEach((root, i) => {
437
+ const trigger = root.querySelector('.select-trigger');
438
+ const menu = root.querySelector('.select-menu');
439
+ const options = [...root.querySelectorAll('[role="option"]')];
440
+ const empty = root.querySelector('.palette-empty');
441
+
442
+ /* A div trigger stays focusable under aria-disabled (that is the pattern:
443
+ it can announce why it cannot act), and an input's disabled state stops its
444
+ own events but not a clear sitting beside it, so every activation path
445
+ reads the state here rather than trusting the pointer's `none`. It has to
446
+ exist before the adapter builds: the multi adapter wires chip listeners at
447
+ construction, and a root the guards below return early for (its menu not
448
+ rendered yet) would leave this in its dead zone and throw at the first
449
+ chip click. */
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)));
455
+
456
+ // one value surface per shape: the shipped .select-value, an <input>
457
+ // trigger's value (combobox), or the chips in a multi-select trigger
458
+ const valueAdapter = valueEl => valueEl && ({
459
+ read: () => valueEl.textContent.trim(),
460
+ value: () => valueEl.textContent.trim(),
461
+ pick(opt) {
462
+ selectOnly(opt);
463
+ valueEl.textContent = opt.textContent.trim();
464
+ return true;
465
+ },
466
+ clear() {},
467
+ });
468
+ const comboAdapter = (root, trigger) => {
469
+ let committed = trigger.value.trim();
470
+ const showAll = () => { options.forEach(o => { o.hidden = false; }); if (empty) empty.hidden = true; };
471
+ return {
472
+ read: () => committed,
473
+ value: () => committed,
474
+ // every open starts from the committed value with the full list, so a
475
+ // query a blur left behind cannot pin the panel to one match
476
+ onOpen: () => { trigger.value = committed; showAll(); },
477
+ pick(opt) {
478
+ selectOnly(opt);
479
+ committed = opt.textContent.trim();
480
+ trigger.value = committed;
481
+ return true;
482
+ },
483
+ clear: () => { committed = ''; trigger.value = ''; showAll(); },
484
+ restore: () => { trigger.value = committed; showAll(); },
485
+ };
486
+ };
487
+ const multiAdapter = (root, trigger) => {
488
+ const chips = root.querySelector('.select-chips');
489
+ const picked = () => options.filter(o => o.getAttribute('aria-selected') === 'true')
490
+ .map(o => o.textContent.trim()).join(', ');
491
+ const sync = () => { root.dataset.value = picked(); };
492
+ const removeChip = chip => {
493
+ if (dead()) return;
494
+ const label = chip.querySelector('span')?.textContent ?? '';
495
+ const opt = options.find(o => o.textContent.trim() === label);
496
+ if (opt) opt.setAttribute('aria-selected', 'false');
497
+ chip.remove();
498
+ sync();
499
+ announce();
500
+ };
501
+ // one wiring path for a chip whatever created it: the root's once mark
502
+ // never covers one, since construction wires before that guard runs
503
+ const wireChip = chip => {
504
+ if (!once(chip, 'uiSelectChip')) return;
505
+ chip.addEventListener('click', e => { e.stopPropagation(); removeChip(chip); });
506
+ };
507
+ const addChip = label => {
508
+ if (!chips) return;
509
+ const chip = document.createElement('button');
510
+ chip.type = 'button';
511
+ chip.className = 'tag filter-chip select-chip';
512
+ chip.setAttribute('aria-label', `remove ${label}`);
513
+ chip.innerHTML = '<span></span><svg class="icon close" aria-hidden="true"><use href="#i-x"/></svg>';
514
+ chip.querySelector('span').textContent = label;
515
+ wireChip(chip);
516
+ chips.appendChild(chip);
517
+ };
518
+ // the fold looks like a chip, but it is authored summary text: wiring
519
+ // it as removable deletes the indicator on the click that should open
520
+ chips?.querySelectorAll('.select-chip:not(.select-chip-more)').forEach(wireChip);
521
+ return {
522
+ read: picked,
523
+ value: () => root.dataset.value,
524
+ pick(opt) {
525
+ const on = opt.getAttribute('aria-selected') === 'true';
526
+ opt.setAttribute('aria-selected', String(!on));
527
+ const label = opt.textContent.trim();
528
+ if (on) {
529
+ const chip = [...(chips?.querySelectorAll('.select-chip') ?? [])]
530
+ .find(c => c.querySelector('span')?.textContent === label);
531
+ chip?.remove();
532
+ } else {
533
+ addChip(label);
534
+ }
535
+ sync();
536
+ return false; // aria-multiselectable: a toggle does not close the panel
537
+ },
538
+ clear() {
539
+ options.forEach(o => o.setAttribute('aria-selected', 'false'));
540
+ chips?.querySelectorAll('.select-chip').forEach(c => c.remove());
541
+ sync();
542
+ },
543
+ };
544
+ };
545
+
546
+ const adapter = !trigger ? null
547
+ : root.classList.contains('select-multi') ? multiAdapter(root, trigger)
548
+ : trigger.tagName === 'INPUT' ? comboAdapter(root, trigger)
549
+ : valueAdapter(root.querySelector('.select-value'));
550
+ if (!trigger || !menu || !options.length || !adapter) return;
551
+ if (!once(root, 'uiSelect')) return;
552
+
553
+ menu.id ||= `select-${i}-menu`;
554
+ trigger.setAttribute('aria-controls', menu.id);
555
+ options.forEach((o, j) => { o.id ||= `select-${i}-opt-${j}`; });
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
+
582
+ const visible = () => options.filter(o => !o.hidden);
583
+ const selectedVisible = () => {
584
+ const vis = visible();
585
+ const at = vis.findIndex(o => o.getAttribute('aria-selected') === 'true');
586
+ return at >= 0 ? at : 0;
587
+ };
588
+ let focusIdx = selectedVisible();
589
+ // the value surface exists from load, or a consumer reading it before the
590
+ // first pick disagrees with what aria-selected already announces
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(); };
595
+ let closingTimer = 0;
596
+ const isOpen = () => root.classList.contains('open');
597
+ // read off --fold, the move itself, so a token change or reduced motion
598
+ // retimes the hold without touching this number
599
+ const exitMs = () => transMs(root);
600
+
601
+ const revealFocused = opt => {
602
+ const top = opt.offsetTop;
603
+ scrollIntoBox(menu, top, top + opt.offsetHeight);
604
+ };
605
+
606
+ const markFocus = idx => {
607
+ const vis = visible();
608
+ if (!vis.length) {
609
+ trigger.removeAttribute('aria-activedescendant');
610
+ options.forEach(o => o.classList.remove('focus'));
611
+ return;
612
+ }
613
+ focusIdx = ((idx % vis.length) + vis.length) % vis.length;
614
+ const opt = vis[focusIdx];
615
+ options.forEach(o => o.classList.toggle('focus', o === opt));
616
+ trigger.setAttribute('aria-activedescendant', opt.id);
617
+ revealFocused(opt);
618
+ };
619
+
620
+ const close = ({ focus = false } = {}) => {
621
+ if (!isOpen()) return;
622
+ root.classList.remove('open');
623
+ // .closing keeps the body's rules applying while --fold runs back to 1;
624
+ // it drops on a frame where every value already equals the closed one
625
+ root.classList.add('closing');
626
+ clearTimeout(closingTimer);
627
+ closingTimer = setTimeout(() => root.classList.remove('closing'), exitMs());
628
+ trigger.setAttribute('aria-expanded', 'false');
629
+ trigger.removeAttribute('aria-activedescendant');
630
+ options.forEach(o => o.classList.remove('focus'));
631
+ if (focus) trigger.focus();
632
+ // .select-up stays: dropping it mid-exit flips the panel across the trigger
633
+ };
634
+
635
+ const open = () => {
636
+ if (dead() || isOpen()) return;
637
+ clearTimeout(closingTimer);
638
+ root.classList.remove('closing');
639
+ selectControllers.forEach(c => { if (c.root !== root) c.close(); });
640
+ adapter.onOpen?.();
641
+ // the side with room wins; visibility:hidden still lays out, so this measures
642
+ const r = trigger.getBoundingClientRect();
643
+ const below = innerHeight - r.bottom;
644
+ root.classList.toggle('select-up', below < menu.offsetHeight + 8 && r.top > below);
645
+ root.classList.add('open');
646
+ trigger.setAttribute('aria-expanded', 'true');
647
+ markFocus(selectedVisible());
648
+ };
649
+
650
+ const pick = opt => {
651
+ if (!opt) return;
652
+ if (adapter.pick(opt)) close({ focus: true });
653
+ commit();
654
+ };
655
+
656
+ const filter = () => {
657
+ const words = queryWords(trigger.value.trim());
658
+ options.forEach(opt => {
659
+ opt.hidden = !matchesQuery(opt.textContent.trim().toLowerCase(), words);
660
+ });
661
+ if (empty) empty.hidden = visible().length > 0;
662
+ markFocus(selectedVisible());
663
+ };
664
+
665
+ // an <input> trigger edits the query: focus opens, Space types instead of
666
+ // committing, and clicking never closes. A button or div trigger toggles.
667
+ if (trigger.tagName === 'INPUT') {
668
+ trigger.addEventListener('focus', open);
669
+ trigger.addEventListener('input', filter);
670
+ }
671
+ trigger.addEventListener('click', () => {
672
+ if (trigger.tagName === 'INPUT') open();
673
+ else isOpen() ? close() : open();
674
+ });
675
+ trigger.addEventListener('blur', () => close());
676
+ trigger.addEventListener('keydown', e => {
677
+ // a key on a chip or the clear belongs to that control: the trigger's
678
+ // own handling would swallow the activation it should not answer for
679
+ if (e.target !== trigger) return;
680
+ switch (e.key) {
681
+ case 'ArrowDown': e.preventDefault(); isOpen() ? markFocus(focusIdx + 1) : open(); break;
682
+ case 'ArrowUp': e.preventDefault(); isOpen() ? markFocus(focusIdx - 1) : open(); break;
683
+ case 'Home': if (isOpen()) { e.preventDefault(); markFocus(0); } break;
684
+ case 'End': if (isOpen()) { e.preventDefault(); markFocus(visible().length - 1); } break;
685
+ case 'Enter': e.preventDefault(); isOpen() ? pick(visible()[focusIdx]) : open(); break;
686
+ case ' ': if (trigger.tagName !== 'INPUT') { e.preventDefault(); isOpen() ? pick(visible()[focusIdx]) : open(); } break;
687
+ case 'Escape': if (isOpen()) { e.preventDefault(); adapter.restore?.(); close({ focus: true }); } break;
688
+ case 'Tab': close(); break;
689
+ }
690
+ });
691
+
692
+ // the panel must not take focus off the trigger, or the blur above closes
693
+ // it before the option's own click can land
694
+ menu.addEventListener('mousedown', e => e.preventDefault());
695
+ options.forEach(opt => {
696
+ opt.addEventListener('click', () => pick(opt));
697
+ opt.addEventListener('pointermove', () => {
698
+ const pos = visible().indexOf(opt);
699
+ if (pos >= 0 && pos !== focusIdx) markFocus(pos);
700
+ });
701
+ });
702
+
703
+ root.querySelector('.select-clear')?.addEventListener('click', e => {
704
+ e.stopPropagation();
705
+ if (dead()) return;
706
+ adapter.clear();
707
+ commit();
708
+ });
709
+
710
+ selectControllers.push({ root, close });
711
+ });
712
+
713
+ if (once(document.body, 'uiSelectOutside')) closeOnOutside(selectControllers);
714
+ }
715
+
716
+ /* ============================= palette ============================ */
717
+
718
+ /* The query field keeps DOM focus for the whole interaction and the list is a
719
+ listbox, so the arrow-key row is named by aria-activedescendant. The controller
720
+ owns nothing a command does: it dispatches `run` on the backdrop with the row's
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
+
782
+ export function initPalette() {
783
+ // the sheet's own catalogue anchor is named `palette`, so an id lookup finds
784
+ // the section that documents the component instead of the live palette
785
+ const backdrop = document.querySelector('.palette-backdrop');
786
+ if (!backdrop || !once(backdrop, 'uiPalette')) return;
787
+ const panel = backdrop.querySelector('.palette');
788
+ const input = backdrop.querySelector('.palette-input');
789
+ const list = backdrop.querySelector('.palette-list');
790
+ const empty = backdrop.querySelector('.palette-empty');
791
+ const announcer = backdrop.querySelector('[role="status"]');
792
+ const trigger = document.getElementById('palette-trigger');
793
+ if (!panel || !input || !list) return;
794
+ const rows = [...backdrop.querySelectorAll('.palette-row')];
795
+ const groups = [...backdrop.querySelectorAll('.palette-group')];
796
+
797
+ // a row matches on its own name plus its group's label, so "navigate" pulls
798
+ // the whole group. Keycaps stay out of it: nobody searches for a shortcut.
799
+ rows.forEach((row, i) => {
800
+ row.id ||= `palette-opt-${i}`;
801
+ const label = row.closest('.palette-group')?.querySelector('.palette-group-label');
802
+ row.dataset.hay = `${row.querySelector('.palette-name')?.textContent || ''} ${label?.textContent || ''}`
803
+ .toLowerCase().trim().replace(/\s+/g, ' ');
804
+ });
805
+
806
+ let shown = rows.slice();
807
+ let activeIdx = 0;
808
+ let lastFocus = null;
809
+ let closeTimer = 0;
810
+
811
+ // .closing counts as closed: a second Escape mid-exit must not restart the
812
+ // exit, and a reopen mid-exit must not return early
813
+ const isOpen = () => !backdrop.hidden && !backdrop.classList.contains('closing');
814
+
815
+ // a row that opens its group scrolls the group in, or Home lands on a cut-off label
816
+ const reveal = row => {
817
+ const group = row.parentElement;
818
+ const opensGroup = row === group.querySelector(VISIBLE_ROW);
819
+ scrollIntoBox(list, (opensGroup ? group : row).offsetTop, row.offsetTop + row.offsetHeight);
820
+ };
821
+
822
+ const markActive = i => {
823
+ if (!shown.length) { input.removeAttribute('aria-activedescendant'); return; }
824
+ activeIdx = (i + shown.length) % shown.length;
825
+ rows.forEach(r => { r.classList.remove('active'); r.setAttribute('aria-selected', 'false'); });
826
+ const row = shown[activeIdx];
827
+ row.classList.add('active');
828
+ row.setAttribute('aria-selected', 'true');
829
+ input.setAttribute('aria-activedescendant', row.id);
830
+ reveal(row);
831
+ };
832
+
833
+ const filter = () => {
834
+ const typed = input.value.trim();
835
+ const words = queryWords(typed);
836
+ shown = [];
837
+ rows.forEach(row => {
838
+ const hit = matchesQuery(row.dataset.hay, words);
839
+ row.hidden = !hit;
840
+ if (hit) shown.push(row);
841
+ });
842
+ groups.forEach(g => { g.hidden = !g.querySelector(VISIBLE_ROW); });
843
+ const none = !shown.length;
844
+ list.hidden = none;
845
+ if (empty) empty.hidden = !none;
846
+ input.setAttribute('aria-expanded', String(!none));
847
+ if (none) {
848
+ const msg = words.length > 1
849
+ ? `No match for '${typed}'. Try fewer words.`
850
+ : `No match for '${typed}'. Try another word.`;
851
+ if (empty) empty.textContent = msg;
852
+ // the visible row is content; this region is what announces it, and it
853
+ // stays in the tree so the change lands on a live region
854
+ if (announcer) announcer.textContent = msg;
855
+ input.removeAttribute('aria-activedescendant');
856
+ } else {
857
+ if (announcer) announcer.textContent = '';
858
+ markActive(0);
859
+ }
860
+ };
861
+
862
+ const open = () => {
863
+ if (isOpen()) return;
864
+ clearTimeout(closeTimer);
865
+ backdrop.classList.remove('closing');
866
+ panel.classList.remove('closing');
867
+ lastFocus = document.activeElement;
868
+ backdrop.hidden = false;
869
+ input.value = '';
870
+ list.scrollTop = 0; // a hidden box keeps the last open's scroll position
871
+ filter();
872
+ input.focus();
873
+ };
874
+
875
+ const close = () => {
876
+ if (!isOpen()) return;
877
+ clearTimeout(closeTimer);
878
+ closeTimer = runExit(backdrop, panel, () => animMs(panel));
879
+ // focus goes back where it came from, or to the control that names the palette
880
+ const back = lastFocus && lastFocus.isConnected && lastFocus !== document.body ? lastFocus : trigger;
881
+ back?.focus();
882
+ };
883
+
884
+ // the palette names the command and closes; what a command does belongs to
885
+ // the page, keyed on data-command rather than on a label that gets reworded
886
+ const run = row => {
887
+ backdrop.dispatchEvent(new CustomEvent('run', {
888
+ bubbles: true,
889
+ detail: { command: row.dataset.command, name: row.querySelector('.palette-name')?.textContent },
890
+ }));
891
+ close();
892
+ };
893
+
894
+ panel.addEventListener('keydown', e => {
895
+ switch (e.key) {
896
+ case 'ArrowDown': e.preventDefault(); markActive(activeIdx + 1); break;
897
+ case 'ArrowUp': e.preventDefault(); markActive(activeIdx - 1); break;
898
+ case 'Home': e.preventDefault(); markActive(0); break;
899
+ case 'End': e.preventDefault(); markActive(shown.length - 1); break;
900
+ case 'Enter': if (shown.length) { e.preventDefault(); run(shown[activeIdx]); } break;
901
+ case 'Escape': e.preventDefault(); close(); break;
902
+ // one focusable element under an aria-modal, so Tab steps the row
903
+ // instead of walking focus into a document assistive tech cannot see
904
+ case 'Tab': e.preventDefault(); markActive(activeIdx + (e.shiftKey ? -1 : 1)); break;
905
+ }
906
+ });
907
+ input.addEventListener('input', filter);
908
+
909
+ // a press anywhere but the query field keeps focus on the query field
910
+ panel.addEventListener('mousedown', e => { if (e.target !== input) e.preventDefault(); });
911
+ backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) e.preventDefault(); });
912
+ backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
913
+
914
+ rows.forEach(row => {
915
+ row.addEventListener('click', () => run(row));
916
+ row.addEventListener('pointermove', () => {
917
+ const i = shown.indexOf(row);
918
+ if (i >= 0 && i !== activeIdx) markActive(i);
919
+ });
920
+ });
921
+
922
+ trigger?.addEventListener('click', open);
923
+ addEventListener('keydown', e => {
924
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open(); }
925
+ });
926
+ }
927
+
928
+ /* ============================= tooltip ============================ */
929
+
930
+ const tooltipControllers = [];
931
+
932
+ export function initTooltip() {
933
+ document.querySelectorAll('.tooltip').forEach(tip => {
934
+ const root = tip.parentElement;
935
+ const trigger = root && root.querySelector(`[aria-describedby="${tip.id}"]`);
936
+ if (!root || !trigger || !once(tip, 'uiTooltip')) return;
937
+
938
+ let hovering = false, focused = false, hideTimer = 0, hoverTimer = 0;
939
+ const isOpen = () => !tip.hidden;
940
+
941
+ const place = () => {
942
+ const r = root.getBoundingClientRect();
943
+ const w = tip.offsetWidth, h = tip.offsetHeight;
944
+ // no room above and room below: open toward the space that exists
945
+ tip.classList.toggle('below', r.top < h + 12 && innerHeight - r.bottom > h + 12);
946
+ let x = (r.width - w) / 2; // centered on the trigger
947
+ x = Math.max(8 - r.left, Math.min(x, innerWidth - 8 - w - r.left));
948
+ tip.style.left = Math.round(x) + 'px';
949
+ };
950
+
951
+ const open = () => {
952
+ clearTimeout(hoverTimer);
953
+ clearTimeout(hideTimer);
954
+ tip.classList.remove('closing'); // a reopen mid-exit restarts the entrance
955
+ if (isOpen()) { place(); return; }
956
+ tip.hidden = false;
957
+ place();
958
+ };
959
+ const close = () => {
960
+ if (!isOpen()) return;
961
+ clearTimeout(hideTimer);
962
+ hideTimer = runExit(tip, null, () => animMs(tip));
963
+ };
964
+ const maybeClose = () => { if (!hovering && !focused) close(); };
965
+
966
+ trigger.addEventListener('pointerenter', e => {
967
+ if (e.pointerType !== 'mouse') return;
968
+ hovering = true;
969
+ // hover intent: 300ms, so a scan across the toolbar does not flicker
970
+ clearTimeout(hoverTimer);
971
+ hoverTimer = setTimeout(open, 300);
972
+ });
973
+ trigger.addEventListener('pointerleave', e => {
974
+ if (e.pointerType !== 'mouse') return;
975
+ hovering = false;
976
+ clearTimeout(hoverTimer);
977
+ maybeClose();
978
+ });
979
+ // touch has no hover: the first contact shows it
980
+ trigger.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') 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
+ });
987
+ trigger.addEventListener('blur', () => { focused = false; maybeClose(); });
988
+ trigger.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
989
+
990
+ tooltipControllers.push({ root, tip, close, place, isOpen });
991
+ });
992
+
993
+ if (once(document.body, 'uiTooltipGlobal')) {
994
+ closeOnOutside(tooltipControllers);
995
+ // an open tooltip re-places on resize; it is anchored to its wrapper,
996
+ // so scrolling carries it with the page
997
+ addEventListener('resize', () => {
998
+ tooltipControllers.forEach(c => { if (c.isOpen()) c.place(); });
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+ /* ============================== modal ============================= */
1004
+
1005
+ /* A dialog opens from `[data-modal-open="<backdrop id>"]` and closes on Escape, on
1006
+ the backdrop's own click, and on any `[data-modal-close]` inside it.
1007
+
1008
+ THE FOCUS PROTOCOL IS THE PART EVERY CONSUMER GOT WRONG, so it lives here rather
1009
+ than in prose. On open, every other child of <body> goes `inert`: that is the
1010
+ focus trap, with no key handling and no tabbable-list arithmetic, and it also
1011
+ takes the page behind out of the accessibility tree. Focus lands on the panel's
1012
+ first close control, and returns to the opener at close.
1013
+
1014
+ A MENU THAT OPENS A MODAL STEALS FOCUS BACK, because the menu's own restore runs
1015
+ after the dialog has taken it, and Escape then does nothing. Opening on the next
1016
+ frame is what orders the two. */
1017
+ // module scope, not per call: the listeners a first init() registered close over
1018
+ // this map, so a second init() creating a fresh one would leave initModal.open
1019
+ // writing to a map nothing reads
1020
+ const modalState = new WeakMap();
1021
+
1022
+ export function initModal() {
1023
+ const state = modalState;
1024
+
1025
+ const openers = document.querySelectorAll('[data-modal-open]');
1026
+ openers.forEach(btn => {
1027
+ if (!once(btn, 'uiModalOpen')) return;
1028
+ btn.addEventListener('click', () => {
1029
+ const backdrop = document.getElementById(btn.dataset.modalOpen);
1030
+ if (backdrop) requestAnimationFrame(() => openModal(backdrop, btn));
1031
+ });
1032
+ });
1033
+
1034
+ // the palette is a modal backdrop wearing a second class, and its own controller
1035
+ // owns its open, close, focus and exit. Two controllers on one element left it
1036
+ // visible after a command ran, swallowing every click on the page behind it.
1037
+ const dialogs = () => [...document.querySelectorAll('.modal-backdrop:not(.palette-backdrop)')];
1038
+
1039
+ dialogs().forEach(backdrop => {
1040
+ if (!once(backdrop, 'uiModal')) return;
1041
+ backdrop.addEventListener('click', e => {
1042
+ if (e.target === backdrop || e.target.closest('[data-modal-close]')) closeModal(backdrop);
1043
+ });
1044
+ });
1045
+
1046
+ if (once(document.body, 'uiModalEsc')) {
1047
+ addEventListener('keydown', e => {
1048
+ if (e.key !== 'Escape') return;
1049
+ const open = dialogs().filter(b => !b.hidden && !b.classList.contains('closing')).pop();
1050
+ if (open) closeModal(open);
1051
+ });
1052
+ }
1053
+
1054
+ /* The ambient layer is not page content, so the trap never reaches it: the ring
1055
+ and the grid paint, the toast stack still announces, and the drawn scrollbar
1056
+ keeps taking a drag. `inert` on that bar would leave the dialog's own scroller
1057
+ with no bar to grab, since the platform's is hidden by then. */
1058
+ function siblings(backdrop) {
1059
+ return [...document.body.children].filter(el =>
1060
+ el !== backdrop && el.id !== 'cursor' && el.id !== 'bg-dots' && el.id !== 'toast-stack'
1061
+ && !el.classList.contains('ui-scrollbar'));
1062
+ }
1063
+
1064
+ function openModal(backdrop, opener) {
1065
+ const s = state.get(backdrop) || {};
1066
+ clearTimeout(s.timer);
1067
+ backdrop.classList.remove('closing');
1068
+ backdrop.querySelector('.modal')?.classList.remove('closing');
1069
+ s.opener = opener || document.activeElement;
1070
+ s.inert = siblings(backdrop).filter(el => !el.inert);
1071
+ s.inert.forEach(el => { el.inert = true; });
1072
+ state.set(backdrop, s);
1073
+ backdrop.hidden = false;
1074
+ const focusTarget = backdrop.querySelector('[data-modal-autofocus]')
1075
+ || backdrop.querySelector('[data-modal-close]')
1076
+ || backdrop.querySelector('.modal');
1077
+ focusTarget?.focus?.();
1078
+ }
1079
+
1080
+ function closeModal(backdrop) {
1081
+ if (backdrop.hidden || backdrop.classList.contains('closing')) return;
1082
+ const s = state.get(backdrop) || {};
1083
+ const panel = backdrop.querySelector('.modal');
1084
+ clearTimeout(s.timer);
1085
+ s.timer = runExit(backdrop, panel, () => (panel ? animMs(panel) : 180));
1086
+ s.inert?.forEach(el => { el.inert = false; });
1087
+ s.inert = null;
1088
+ state.set(backdrop, s);
1089
+ // an opener inside a popover is hidden the moment its menu folds, and
1090
+ // focusing a hidden element does nothing, so the menu's own trigger is
1091
+ // the fallback. The .closing branch covers the window where the menu
1092
+ // has not hidden yet but will: focus lands on the row and then drops.
1093
+ if (s.opener?.isConnected) s.opener.focus();
1094
+ const menu = s.opener?.closest?.('.popover');
1095
+ if (menu && (menu.hidden || menu.classList.contains('closing'))) {
1096
+ document.querySelector(`[aria-controls="${menu.id}"]`)?.focus();
1097
+ }
1098
+ }
1099
+
1100
+ initModal.open = openModal;
1101
+ initModal.close = closeModal;
1102
+ }
1103
+
1104
+ /* ============================== toasts ============================ */
1105
+
1106
+ /* THE SPRITE IS THE ONE DRAWING OF EVERY GLYPH. These four were redrawn here
1107
+ and each one had drifted from the symbol it stands for: the success tick ran
1108
+ M5 8 l2 2 l4 -4 against the sprite's M5 8 L7 10 L11 6, the warning triangle
1109
+ sat a half unit lower, and both dots were 0.5-unit strokes where the sprite
1110
+ draws a .01 round cap. Nothing could see it, because a redrawn glyph passes
1111
+ every guard the sprite has. */
1112
+ const toastIcons = {
1113
+ success: '<svg aria-hidden="true"><use href="#i-success"/></svg>',
1114
+ warning: '<svg aria-hidden="true"><use href="#i-warning"/></svg>',
1115
+ danger: '<svg aria-hidden="true"><use href="#i-error"/></svg>',
1116
+ info: '<svg aria-hidden="true"><use href="#i-info"/></svg>',
1117
+ };
1118
+ const toastIconColors = {
1119
+ success: 'var(--success)', warning: 'var(--warning)',
1120
+ danger: 'var(--danger)', info: 'var(--accent)',
1121
+ };
1122
+
1123
+ /* `title` and `msg` are caller strings: a service name, a log line, a filename.
1124
+ They go in as TEXT. The markup around them is the only thing built from a
1125
+ template, so a value carrying angle brackets lands as characters, not markup. */
1126
+ export function showToast(type, title, msg) {
1127
+ const stack = document.getElementById('toast-stack');
1128
+ if (!stack) return null;
1129
+ const t = document.createElement('div');
1130
+ t.className = 'toast';
1131
+ t.setAttribute('role', 'status');
1132
+ t.innerHTML = `
1133
+ <div class="toast-icon" style="color:${toastIconColors[type] || toastIconColors.info}">${toastIcons[type] || toastIcons.info}</div>
1134
+ <div class="toast-body">
1135
+ <div class="toast-title"></div>
1136
+ <div class="toast-msg"></div>
1137
+ </div>
1138
+ <button class="close" aria-label="Dismiss">
1139
+ <svg aria-hidden="true"><use href="#i-x"/></svg>
1140
+ </button>`;
1141
+ t.querySelector('.toast-title').textContent = title;
1142
+ t.querySelector('.toast-msg').textContent = msg;
1143
+ wireDismiss(t.querySelector('.close'));
1144
+ stack.appendChild(t);
1145
+ setTimeout(() => removeToast(t), 5000);
1146
+ return t;
1147
+ }
1148
+
1149
+ export function removeToast(t) {
1150
+ if (!t || !t.parentNode) return;
1151
+ t.classList.add('removing');
1152
+ setTimeout(() => t.remove(), 110); // --dur-out-near, the toast's own exit
1153
+ }
1154
+
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));
1167
+ });
1168
+ };
1169
+
1170
+ export function initDismiss() {
1171
+ document.querySelectorAll('.close').forEach(wireDismiss);
1172
+ }
1173
+
1174
+ /* ============================== theme ============================= */
1175
+
1176
+ /* The saved theme is restored in <head> before the stylesheet loads, so a light
1177
+ visitor never sees a dark flash; that snippet is in SKILL.md and cannot live
1178
+ here, because a module runs too late to beat the first paint. */
1179
+ export function setTheme(t) {
1180
+ document.documentElement.dataset.theme = t;
1181
+ try { localStorage.setItem('cloudy-theme', t); } catch { /* private mode */ }
1182
+ document.querySelectorAll('[data-theme-toggle]').forEach(b => {
1183
+ b.setAttribute('aria-pressed', String(t === 'light'));
1184
+ b.setAttribute('aria-label', t === 'light' ? 'Switch to dark theme' : 'Switch to light theme');
1185
+ });
1186
+ }
1187
+
1188
+ /* Exported, because the button is not the only thing that swaps a theme: a palette
1189
+ command and a keyboard shortcut both want to call this, and inlining it in the
1190
+ click handler is what made a consumer write its own copy. */
1191
+ export function toggleTheme() {
1192
+ const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
1193
+ // the crossfade snapshots the old frame and fades the two together; a
1194
+ // browser without view transitions gets the same swap on the spot
1195
+ if (!document.startViewTransition) return setTheme(next);
1196
+ document.startViewTransition(() => setTheme(next));
1197
+ }
1198
+
1199
+ export function initTheme() {
1200
+ setTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
1201
+ document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
1202
+ if (!once(btn, 'uiTheme')) return;
1203
+ btn.addEventListener('click', toggleTheme);
1204
+ });
1205
+ }
1206
+
1207
+ /* ====================== reveals and sticky headers ====================== */
1208
+
1209
+ /* --vp-w is the viewport width minus a classic scrollbar, which 100vw wrongly
1210
+ includes. Every full-bleed rule reads it (.band, .section-sticky.is-stuck), so
1211
+ it is written unconditionally: the old split, where only the reveal controller
1212
+ wrote it, shipped a horizontal scrollbar on any page with a sticky header and
1213
+ no reveals. In a sidebar shell, call this with the scrolling column instead. */
1214
+ export function initViewportWidth(el = document.documentElement) {
1215
+ const set = () => el.style.setProperty('--vp-w', el.clientWidth + 'px');
1216
+ set();
1217
+ // the guard is per element, not global: a sidebar shell calls this a second time
1218
+ // for its scrolling column, and a global latch would leave that column's --vp-w
1219
+ // correct at boot and frozen on every resize after it
1220
+ if (once(el === document.documentElement ? document.body : el, 'uiVpw')) {
1221
+ addEventListener('resize', set);
1222
+ }
1223
+ }
1224
+
1225
+ /* A 1px sentinel above each header toggles .is-stuck. Shipped in two places
1226
+ before this file existed, so a page pasting both blocks inserted two sentinels
1227
+ per header and toggled the class twice per scroll.
1228
+
1229
+ A SENTINEL IS OUT OF VIEW IN BOTH DIRECTIONS, so `!isIntersecting` alone reads
1230
+ "the reader has not reached this section yet" as "this header is stuck". The
1231
+ stuck costume is a full-bleed page-toned band, so the reference sheet's one
1232
+ header painted that band at its natural position for the whole scroll and
1233
+ dropped it on arrival, the state backwards from top to bottom: measured, stuck
1234
+ with its top 14080px below the fold and unstuck with its top at 80. Which side
1235
+ the sentinel left on is the whole state, so the side is what the test reads. */
1236
+ export function initSticky() {
1237
+ document.querySelectorAll('.section-sticky').forEach(h => {
1238
+ if (!once(h, 'uiSticky')) return;
1239
+ const sentinel = document.createElement('div');
1240
+ h.parentNode.insertBefore(sentinel, h);
1241
+ /* THE PIN OFFSET IS A BREAKPOINT, not a constant: the header pins under the
1242
+ navbar at 56px above 768 and under the mobile topbar at 52 below it, and
1243
+ both the rootMargin and the side test are built from it. Read once, a
1244
+ viewport crossing 768 left the class flipping 4px early, exactly the
1245
+ difference between the two bars. `initViewportWidth` in this same section
1246
+ already re-reads on resize for the same reason. */
1247
+ let io = null;
1248
+ const arm = () => {
1249
+ const top = parseInt(getComputedStyle(h).top) || 0;
1250
+ if (io) io.disconnect();
1251
+ io = new IntersectionObserver(
1252
+ ([e]) => h.classList.toggle('is-stuck',
1253
+ !e.isIntersecting && e.boundingClientRect.top < top + 1),
1254
+ { rootMargin: `-${top + 1}px 0px 0px 0px`, threshold: 1 }
1255
+ );
1256
+ io.observe(sentinel);
1257
+ };
1258
+ arm();
1259
+ addEventListener('resize', arm);
1260
+ });
1261
+ }
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
+
1314
+ /* JS adds the hiding class at runtime, so no-JS keeps the content visible.
1315
+ Reduced motion skips the hide entirely rather than zeroing a duration. */
1316
+ export function initReveal() {
1317
+ if (reducedMotion() || !('IntersectionObserver' in window)) return;
1318
+
1319
+ const targets = [...document.querySelectorAll('[data-reveal]')].filter(el => once(el, 'uiReveal'));
1320
+ if (!targets.length) return;
1321
+ targets.forEach(el => el.classList.add('reveal')); // hiding class added by JS, never authored
1322
+
1323
+ /* The stagger belongs to what arrives TOGETHER, so it is applied at reveal time
1324
+ rather than at init. A document-order index makes a section that crosses the
1325
+ trigger on its own wait for a position the reader cannot see: measured on the
1326
+ sheet, section 5 first moved at 250ms and settled at 399, its neighbour 6
1327
+ moved at 60 and settled at 211, on no grouping reason. 40ms, capped at six. */
1328
+ const land = els => els.forEach((el, i) => {
1329
+ el.style.transitionDelay = Math.min(i, 5) * 40 + 'ms';
1330
+ el.classList.add('in');
1331
+ });
1332
+ const settle = watchEntrance(targets, land);
1333
+
1334
+ /* The net covers an observer that never fired, so it reveals only what is ON
1335
+ SCREEN when it lands. Revealing every target instead settles the whole page
1336
+ before the reader reaches any of it: measured on the rendered sheet, 44 of
1337
+ 46 sections were off screen at 1500ms, so no section could ever animate. */
1338
+ setTimeout(() => settle(targets.filter(el => {
1339
+ const r = el.getBoundingClientRect();
1340
+ return r.top < innerHeight && r.bottom > 0;
1341
+ })), 1500);
1342
+ }
1343
+
1344
+ /* ============================= chart entrance ============================= */
1345
+
1346
+ /* JS adds the hiding class at runtime, so a page with no controller paints its
1347
+ marks instead of nothing. `.is-drawn` lands once, when the chart intersects
1348
+ the viewport, and the observer unobserves it: the entrance never replays on a
1349
+ second scroll past. */
1350
+ /* A card whose whole surface is the control cannot be a `<button>`: its content
1351
+ is block-level, and a button may not hold a heading or a paragraph. So it is a
1352
+ div with `role="button"` and a tabindex, and a div gets no activation for
1353
+ free: Enter and Space are wired here, and Space's own default is refused
1354
+ because it scrolls the page. Reaching the card is the consumer's half of the
1355
+ contract (the two attributes); activating it is this system's. */
1356
+ function initCardButtons() {
1357
+ document.querySelectorAll('.card-interactive[role="button"]').forEach(card => {
1358
+ if (!once(card, 'uiCardButton')) return;
1359
+ if (!card.hasAttribute('tabindex')) card.tabIndex = 0;
1360
+ card.addEventListener('keydown', e => {
1361
+ if (e.key !== 'Enter' && e.key !== ' ') return;
1362
+ e.preventDefault();
1363
+ card.click();
1364
+ });
1365
+ });
1366
+ }
1367
+
1368
+ /* A legend built from buttons is a control, and `charts.md` had described this
1369
+ one for a round before anything wrote it: every key on every page was inert
1370
+ text beside a swatch. The toggle names its slot with the same
1371
+ `chart-series-N` class the mark carries, so the pair cannot drift, and it
1372
+ finds its chart by walking up to the first ancestor holding one, which lets a
1373
+ legend sit either side of the figure with nothing to wire. */
1374
+ function initLegend() {
1375
+ document.querySelectorAll('.legend-toggle').forEach(btn => {
1376
+ const slot = [...btn.classList].find(c => /^chart-series-\d+$/.test(c));
1377
+ if (!slot || !once(btn, 'uiLegend')) return;
1378
+ let chart = null;
1379
+ for (let p = btn.parentElement; p && !chart; p = p.parentElement) chart = p.querySelector('.chart');
1380
+ if (!chart) return;
1381
+ btn.addEventListener('click', () => {
1382
+ const on = btn.getAttribute('aria-pressed') !== 'false';
1383
+ btn.setAttribute('aria-pressed', String(!on));
1384
+ chart.querySelectorAll(`.chart-series.${slot}`).forEach(s => s.classList.toggle('is-off', on));
1385
+ });
1386
+ });
1387
+ }
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
+
1436
+ export function initChart() {
1437
+ initLegend();
1438
+ initLineMarks();
1439
+ const charts = [...document.querySelectorAll('.chart')].filter(el => once(el, 'uiChart'));
1440
+ if (!charts.length) return;
1441
+
1442
+ // the hiding class lands in this same synchronous task, before the first paint
1443
+ charts.forEach(el => el.classList.add('will-draw'));
1444
+
1445
+ if (!('IntersectionObserver' in window)) {
1446
+ charts.forEach(el => el.classList.add('is-drawn'));
1447
+ return;
1448
+ }
1449
+ watchEntrance(charts, els => els.forEach(el => el.classList.add('is-drawn')));
1450
+ }
1451
+
1452
+ /* ========================== popovers and menus ========================== */
1453
+
1454
+ /* Any `.popover` opened by a button that names it with `aria-controls` AND
1455
+ `aria-haspopup`. Covers the table's filter menu and the context menu, and it is
1456
+ what `.popover` needed to stop being an entrance with no exit: the exit runs the
1457
+ `.closing` costume for the popover's own animation duration.
1458
+
1459
+ THE GLOBAL HANDLERS TOUCH ONLY THE PAIRS THIS CONTROLLER WIRED. `.popover` is
1460
+ also the shell the palette and the select panel are built from, so a handler
1461
+ written against every `.popover` on the page reaches into components that own
1462
+ their own open state: it hid the palette's panel behind its own backdrop, and
1463
+ the next open showed an empty dialog that swallowed every click on the page. */
1464
+ const popoverPairs = [];
1465
+
1466
+ export function initPopovers() {
1467
+ const close = (menu, btn) => {
1468
+ if (menu.hidden || menu.classList.contains('closing')) return;
1469
+ btn?.setAttribute('aria-expanded', 'false');
1470
+ menu.querySelectorAll('.menu-item.focus').forEach(r => r.classList.remove('focus'));
1471
+ menu.uiPopoverExit = runExit(menu, null, () => animMs(menu));
1472
+ };
1473
+
1474
+ document.querySelectorAll('[aria-controls][aria-haspopup]').forEach(btn => {
1475
+ const menu = document.getElementById(btn.getAttribute('aria-controls'));
1476
+ if (!menu || !menu.classList.contains('popover') || !once(btn, 'uiPopover')) return;
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
+
1486
+ // a checkbox row has to survive several picks, so an input inside the
1487
+ // item keeps the menu open; a button row is an action, and an action
1488
+ // row that opens a modal leaves a menu open behind the dialog, whose
1489
+ // Escape handler then eats the dialog's
1490
+ menu.addEventListener('click', e => {
1491
+ const item = e.target.closest?.('.menu-item');
1492
+ if (!item || (isControl(e.target) && !e.target.closest?.('button.menu-item'))) return;
1493
+ close(menu, btn);
1494
+ btn.focus();
1495
+ });
1496
+ btn.addEventListener('click', () => {
1497
+ if (!menu.hidden) return close(menu, btn);
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
+ }
1587
+ });
1588
+ });
1589
+ if (!once(document.body, 'uiPopoverGlobal')) return;
1590
+
1591
+ const openPairs = () => popoverPairs.filter(p => !p.menu.hidden);
1592
+ document.addEventListener('pointerdown', e => {
1593
+ openPairs().forEach(({ btn, menu }) => {
1594
+ if (menu.contains(e.target) || btn.contains(e.target)) return;
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();
1599
+ });
1600
+ });
1601
+ document.addEventListener('keydown', e => {
1602
+ if (e.key !== 'Escape') return;
1603
+ const open = openPairs();
1604
+ if (!open.length) return;
1605
+ e.stopPropagation(); // a menu's Escape belongs to the menu, not to a dialog behind it
1606
+ open.forEach(({ btn, menu }) => { close(menu, btn); btn.focus(); });
1607
+ });
1608
+ }
1609
+
1610
+ /* ============================== tables ============================= */
1611
+
1612
+ /* The DOM half of sort, selection and paging. The data half belongs to the page,
1613
+ which renders rows and listens for these events:
1614
+ `table:sort` {key, dir} dir is 'asc' | 'desc' | 'none'
1615
+ `table:selection` {selected} the data-name values currently checked
1616
+ `table:page` {page}
1617
+ Every one of them bubbles from the `.table-wrap`. Nothing here needs to know
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
+
1621
+ export function initTables() {
1622
+ const ariaSort = { asc: 'ascending', desc: 'descending' };
1623
+
1624
+ document.querySelectorAll('.table-wrap').forEach(wrap => {
1625
+ // outside the once() guard, so a table that re-renders its head is wrapped again.
1626
+ // A sortable cell's text lives in the button, not the cell, so both are offered.
1627
+ wrap.querySelectorAll('thead th, thead .th-sort').forEach(el => ownLabel(el, 'th-label'));
1628
+ if (!once(wrap, 'uiTable')) return;
1629
+ const state = { key: null, dir: 'none' };
1630
+
1631
+ wrap.querySelectorAll('.th-sort').forEach(btn => {
1632
+ btn.addEventListener('click', () => {
1633
+ const key = btn.dataset.sort;
1634
+ const th = btn.closest('th');
1635
+ if (state.key === key) {
1636
+ state.dir = state.dir === 'none' ? 'asc' : state.dir === 'asc' ? 'desc' : 'none';
1637
+ } else { state.key = key; state.dir = 'asc'; }
1638
+ wrap.querySelectorAll('th[aria-sort]').forEach(h => {
1639
+ h.setAttribute('aria-sort', h === th && state.dir !== 'none' ? ariaSort[state.dir] : 'none');
1640
+ });
1641
+ wrap.dispatchEvent(new CustomEvent('table:sort', { bubbles: true, detail: { ...state } }));
1642
+ });
1643
+ });
1644
+
1645
+ const selectAll = wrap.querySelector('input[type="checkbox"][data-select-all], thead input[type="checkbox"]');
1646
+ const boxes = () => [...wrap.querySelectorAll(`tbody ${ROW_BOX}`)];
1647
+ const selected = () => boxes().filter(b => b.checked).map(b => b.dataset.name);
1648
+ const syncAll = () => {
1649
+ if (!selectAll) return;
1650
+ const all = boxes();
1651
+ const n = all.filter(b => b.checked).length;
1652
+ selectAll.checked = all.length > 0 && n === all.length;
1653
+ selectAll.indeterminate = n > 0 && n < all.length;
1654
+ };
1655
+ const announce = () => wrap.dispatchEvent(new CustomEvent('table:selection',
1656
+ { bubbles: true, detail: { selected: selected() } }));
1657
+
1658
+ selectAll?.addEventListener('change', () => {
1659
+ boxes().forEach(b => {
1660
+ b.checked = selectAll.checked;
1661
+ // the class toggles in place, so the selection tint transitions
1662
+ b.closest('tr')?.classList.toggle('row-selected', b.checked);
1663
+ });
1664
+ syncAll();
1665
+ announce();
1666
+ });
1667
+ wrap.addEventListener('change', e => {
1668
+ const b = e.target.closest?.(`tbody ${ROW_BOX}`);
1669
+ if (!b) return;
1670
+ b.closest('tr')?.classList.toggle('row-selected', b.checked);
1671
+ syncAll();
1672
+ announce();
1673
+ });
1674
+ // the row is the target, the same ruling the setting row runs on: a click
1675
+ // anywhere on a selectable row that missed a control drives its own box
1676
+ wrap.addEventListener('click', e => {
1677
+ if (isControl(e.target)) return;
1678
+ e.target.closest('tr')?.querySelector(ROW_BOX)?.click();
1679
+ });
1680
+
1681
+ // a re-rendered body arrives with its own checked state: re-derive rather
1682
+ // than carry the last render's count
1683
+ new MutationObserver(syncAll).observe(wrap, { childList: true, subtree: true });
1684
+ syncAll();
1685
+ });
1686
+
1687
+ /* A pager usually sits OUTSIDE the .table-wrap it drives, so an event dispatched
1688
+ from it bubbles past the wrap and never reaches a listener there. Name the
1689
+ table with `aria-controls` and the event lands on that table's wrap, next to
1690
+ `table:sort` and `table:selection`. With no `aria-controls` it fires on the
1691
+ pager itself, which is then where the page has to listen. */
1692
+ document.querySelectorAll('.pagination').forEach(pager => {
1693
+ if (!once(pager, 'uiPager')) return;
1694
+ pager.addEventListener('click', e => {
1695
+ const b = e.target.closest('button[data-page]');
1696
+ if (!b || b.disabled) return;
1697
+ const named = pager.getAttribute('aria-controls');
1698
+ const target = (named && document.getElementById(named)?.closest('.table-wrap'))
1699
+ || b.closest('.table-wrap') || pager;
1700
+ target.dispatchEvent(new CustomEvent('table:page',
1701
+ { bubbles: true, detail: { page: Number(b.dataset.page) } }));
1702
+ });
1703
+ });
1704
+ }
1705
+
1706
+ /* ============================= code copy =========================== */
1707
+
1708
+ /* Copies the block's text and swaps to a check for a beat. A failed write leaves
1709
+ the button unchanged; there is no state to celebrate. */
1710
+ export function initCodeCopy() {
1711
+ document.querySelectorAll('.code-copy').forEach(btn => {
1712
+ const code = btn.closest('.code-body')?.querySelector('code');
1713
+ if (!code || !once(btn, 'uiCopy')) return;
1714
+ const iconCopy = btn.querySelector('.icon-copy');
1715
+ const iconCheck = btn.querySelector('.icon-check');
1716
+ let timer = 0;
1717
+ btn.addEventListener('click', async () => {
1718
+ try {
1719
+ await navigator.clipboard.writeText(code.textContent);
1720
+ } catch (err) {
1721
+ console.warn('copy failed', err);
1722
+ return;
1723
+ }
1724
+ if (iconCopy) iconCopy.hidden = true;
1725
+ if (iconCheck) iconCheck.hidden = false;
1726
+ btn.setAttribute('aria-label', 'Copied');
1727
+ clearTimeout(timer);
1728
+ timer = setTimeout(() => {
1729
+ if (iconCopy) iconCopy.hidden = false;
1730
+ if (iconCheck) iconCheck.hidden = true;
1731
+ btn.setAttribute('aria-label', 'Copy code');
1732
+ }, 1200);
1733
+ });
1734
+ });
1735
+ }
1736
+
1737
+ /* ======================= typed placeholder ======================= */
1738
+
1739
+ /* The third sanctioned ambient effect. An overlay sitting on the field types one
1740
+ real example, holds it, deletes it, and moves to the next. Ported from the
1741
+ contact form it was built for; the timings are that build's, unchanged.
1742
+
1743
+ A field declares its own examples and nothing else:
1744
+ <input class="input" data-typed='["you@example.com", "discord: cool_person"]'>
1745
+
1746
+ Three things the obvious version gets wrong:
1747
+ - It renders REAL example values, so it is content rather than decoration, and
1748
+ it takes a text colour that clears 4.5:1 rather than a placeholder grey.
1749
+ - A value in the field hides the overlay, so the loop parks at the frame before
1750
+ the next example instead of running invisibly, and resumes on the next clear.
1751
+ Resuming moves forward, so the interrupted example does not replay.
1752
+ - Autofill never fires `input`, so a value already present at boot is checked. */
1753
+ const TYPE_CHAR = 80, TYPE_ERASE = 50, TYPE_HOLD = 5000, TYPE_GAP = 400, TYPE_START = 600;
1754
+
1755
+ export function initTypedPlaceholder() {
1756
+ // ambient motion is off under reduced motion, and this one has no reduced form:
1757
+ // a typed line with the typing removed is just a placeholder the field already has
1758
+ if (reducedMotion()) return;
1759
+
1760
+ document.querySelectorAll('input[data-typed], textarea[data-typed]').forEach(input => {
1761
+ if (!once(input, 'uiTyped')) return;
1762
+ let examples;
1763
+ try {
1764
+ examples = JSON.parse(input.dataset.typed);
1765
+ } catch {
1766
+ console.warn('data-typed is not JSON on', input);
1767
+ return;
1768
+ }
1769
+ if (!Array.isArray(examples) || !examples.length) return;
1770
+
1771
+ const wrap = ownWrap(input, 'typed-wrap');
1772
+ // a marker this module owns, aria-hidden like the inks: the field's own
1773
+ // label names it, so the typed examples need no second voice
1774
+ const overlay = ownChild(wrap, 'typed-overlay');
1775
+
1776
+ let idx = 0, timer = 0, paused = false;
1777
+
1778
+ // one span per character, so each one fades on its own clock
1779
+ const build = text => {
1780
+ overlay.textContent = '';
1781
+ const frag = document.createDocumentFragment();
1782
+ for (const ch of text) {
1783
+ const span = document.createElement('span');
1784
+ span.className = 'typed-char';
1785
+ span.textContent = ch;
1786
+ frag.appendChild(span);
1787
+ }
1788
+ overlay.appendChild(frag);
1789
+ return [...overlay.children];
1790
+ };
1791
+
1792
+ const type = (spans, i, done) => {
1793
+ if (i >= spans.length) { timer = setTimeout(done, TYPE_HOLD); return; }
1794
+ spans[i].style.opacity = '1';
1795
+ timer = setTimeout(() => type(spans, i + 1, done), TYPE_CHAR);
1796
+ };
1797
+ const erase = (spans, i, done) => {
1798
+ if (i < 0) { done(); return; }
1799
+ spans[i].style.opacity = '0';
1800
+ timer = setTimeout(() => erase(spans, i - 1, done), TYPE_ERASE);
1801
+ };
1802
+ const cycle = () => {
1803
+ const spans = build(examples[idx]);
1804
+ new Promise(done => type(spans, 0, done))
1805
+ .then(() => new Promise(done => erase(spans, spans.length - 1, done)))
1806
+ .then(() => {
1807
+ idx = (idx + 1) % examples.length;
1808
+ timer = setTimeout(cycle, TYPE_GAP);
1809
+ });
1810
+ };
1811
+
1812
+ const pause = () => {
1813
+ paused = true;
1814
+ clearTimeout(timer);
1815
+ overlay.textContent = '';
1816
+ idx = (idx + 1) % examples.length; // resume moves forward, never replays
1817
+ };
1818
+ const resume = () => { paused = false; timer = setTimeout(cycle, TYPE_GAP); };
1819
+
1820
+ input.addEventListener('input', () => {
1821
+ const filled = !!input.value;
1822
+ overlay.hidden = filled;
1823
+ if (filled && !paused) pause();
1824
+ else if (!filled && paused) resume();
1825
+ });
1826
+
1827
+ if (input.value) { overlay.hidden = true; pause(); }
1828
+ else timer = setTimeout(cycle, TYPE_START);
1829
+ });
1830
+ }
1831
+
1832
+ /* ============================ scroll fade =========================== */
1833
+
1834
+ /* The cue that a scroller has more. The scrollers carry it themselves, so no
1835
+ consumer opts in: this one selector names every component the stylesheet makes
1836
+ scroll, and the controller writes the cue class onto each. The axis is derived
1837
+ from what the element actually scrolls, never from a class the author wrote.
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
1841
+ page paints no cue. The two has-more classes are the CSS's own gate. */
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;
1853
+
1854
+ export function initScrollFade() {
1855
+ document.querySelectorAll(SCROLL_FADE_SELECTOR).forEach(el => {
1856
+ if (el.classList.contains('no-scroll-fade') || !once(el, 'uiFade')) return;
1857
+ el.classList.add('scroll-fade');
1858
+ const sync = () => {
1859
+ const y = el.scrollHeight > el.clientHeight + 1;
1860
+ el.classList.toggle('scroll-fade-y', y);
1861
+ const pos = y ? el.scrollTop : el.scrollLeft;
1862
+ const view = y ? el.clientHeight : el.clientWidth;
1863
+ const full = y ? el.scrollHeight : el.scrollWidth;
1864
+ el.classList.toggle('has-more-start', pos > 1);
1865
+ el.classList.toggle('has-more-end', pos + view < full - 1);
1866
+ };
1867
+ fadeSyncs.set(el, sync);
1868
+ el.addEventListener('scroll', sync, { passive: true });
1869
+ /* The box changes size with no scroll and no window resize: the rail collapse
1870
+ takes this nav's clientHeight 756 to 722, which left the cue saying nothing
1871
+ was below with 34px still there, until the next scroll. */
1872
+ fadeObserver.observe(el);
1873
+ sync();
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
+ }
1887
+ }
1888
+
1889
+ /* ============================ scrollbar =========================== */
1890
+
1891
+ /* The page draws its own, because `cursor: none` cannot reach a native
1892
+ scrollbar's shadow tree and the platform pointer paints over the ring for as
1893
+ long as one is dragged. `components.css` hides the native bars; this puts a
1894
+ real element back, so the ring paints over it like any other surface.
1895
+
1896
+ ONE bar per axis for the whole page, moved onto whichever scroller is active,
1897
+ which is what an overlay scrollbar already is. Per-scroller bars would need a
1898
+ positioned wrapper around every scrolling component in the system.
1899
+
1900
+ It appears on any pointer move inside a scroller and on a scroll, and retires
1901
+ `SCROLLBAR_IDLE` after both stop. Two things hold it past
1902
+ that: a drag, and the pointer resting on the thumb itself, or the bar would
1903
+ retire out from under a pointer on its way to grab it. */
1904
+ const SCROLLBAR_IDLE = 900;
1905
+ const SCROLLBAR_MIN = 28; // a thumb shorter than this stops reading as a handle
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
1909
+ const SCROLLBAR_HOLD = 10; // slop around a 6px thumb, so the hold is reachable
1910
+ const SCROLLBAR_Z = 999; // the bar's own slot in the stacking ladder
1911
+ const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
1912
+ // sub-pixel rounding in a stuck header's rect, never
1913
+ // a real strip of visible page
1914
+
1915
+ export function initScrollbars() {
1916
+ if (!once(document.documentElement, 'uiScrollbars')) return;
1917
+ const root = document.scrollingElement || document.documentElement;
1918
+ const bars = {
1919
+ y: ownChild(document.body, 'ui-scrollbar ui-scrollbar-y'),
1920
+ x: ownChild(document.body, 'ui-scrollbar ui-scrollbar-x'),
1921
+ };
1922
+ // the stylesheet hides the platform's bars only once these two exist
1923
+ document.documentElement.classList.add('has-drawn-scrollbars');
1924
+
1925
+ const view = (el, axis) => axis === 'y'
1926
+ ? (el === root ? innerHeight : el.clientHeight)
1927
+ : (el === root ? innerWidth : el.clientWidth);
1928
+ const full = (el, axis) => axis === 'y' ? el.scrollHeight : el.scrollWidth;
1929
+ const at = (el, axis) => axis === 'y' ? el.scrollTop : el.scrollLeft;
1930
+
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;
1937
+ if (full(el, axis) <= view(el, axis) + 1) return false;
1938
+ if (el === root) return true;
1939
+ const ov = getComputedStyle(el)[axis === 'y' ? 'overflowY' : 'overflowX'];
1940
+ return ov === 'auto' || ov === 'scroll';
1941
+ };
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) => {
1947
+ for (let el = node; el instanceof Element; el = el.parentElement) {
1948
+ if (scrolls(el, axis)) return el;
1949
+ }
1950
+ return root;
1951
+ };
1952
+ const boxOf = el => el === root
1953
+ ? { top: 0, left: 0, right: innerWidth, bottom: innerHeight }
1954
+ : el.getBoundingClientRect();
1955
+
1956
+ /* A rounded panel takes its own corner out from under the thumb, so a track
1957
+ running the full length leaves the thumb's last pixels outside the paint.
1958
+ The corner is often not the scroller's own: a modal body scrolls inside the
1959
+ modal's radius and declares none itself, so the radius comes from every box
1960
+ that shares the edge the bar runs along. The thumb's outer edge sits
1961
+ SCROLLBAR_GAP in from that edge, and a corner of radius r is inset by more
1962
+ than the gap for the first r - sqrt(2*r*gap - gap^2) px along it: 6.79px on
1963
+ the 14px corner of --r-2 at a 2px gap. The track gives that up at each end.
1964
+
1965
+ Cached per element, because it is layout that decides which ancestors share
1966
+ an edge and the walk would otherwise run on every frame of a scroll. */
1967
+ const inset = r => (r <= SCROLLBAR_GAP ? 0
1968
+ : r - Math.sqrt(2 * r * SCROLLBAR_GAP - SCROLLBAR_GAP * SCROLLBAR_GAP));
1969
+ let radii = new WeakMap();
1970
+ const corner = (el, axis) => {
1971
+ let cached = radii.get(el);
1972
+ if (!cached) radii.set(el, cached = {});
1973
+ if (cached[axis]) return cached[axis];
1974
+ const pads = [0, 0];
1975
+ if (el !== root) {
1976
+ const own = el.getBoundingClientRect();
1977
+ const px = v => (v.includes('px') ? parseFloat(v) || 0 : 0);
1978
+ const same = (a, b) => Math.abs(a - b) <= 1;
1979
+ for (let a = el; a instanceof Element; a = a.parentElement) {
1980
+ const r = a.getBoundingClientRect();
1981
+ const cs = getComputedStyle(a);
1982
+ if (axis === 'y' && same(r.right, own.right)) {
1983
+ if (same(r.top, own.top)) pads[0] = Math.max(pads[0], inset(px(cs.borderTopRightRadius)));
1984
+ if (same(r.bottom, own.bottom)) pads[1] = Math.max(pads[1], inset(px(cs.borderBottomRightRadius)));
1985
+ } else if (axis === 'x' && same(r.bottom, own.bottom)) {
1986
+ if (same(r.left, own.left)) pads[0] = Math.max(pads[0], inset(px(cs.borderBottomLeftRadius)));
1987
+ if (same(r.right, own.right)) pads[1] = Math.max(pads[1], inset(px(cs.borderBottomRightRadius)));
1988
+ }
1989
+ }
1990
+ }
1991
+ cached[axis] = pads;
1992
+ return pads;
1993
+ };
1994
+
1995
+ /* Chrome pinned over the scrollport hides the track under it, and a thumb
1996
+ drawn there marks a position nobody can scroll to. The covers are DERIVED
1997
+ rather than named: every visible fixed or sticky box
1998
+ is a candidate, and one counts against this bar only where it overlaps the
1999
+ bar's own lane and sits below the bar in the stacking ladder.
2000
+
2001
+ A cover counts only CONTIGUOUS with the run it joins: it may start no
2002
+ further than SCROLLBAR_CHAIN past the end already given up, which is what
2003
+ lets a header stuck flush under a navbar extend the run the navbar
2004
+ started. A navbar floating clear of the viewport edge as a card counts
2005
+ for nothing, because the strip of page above it is fully visible and the
2006
+ thumb's origin must not move with the viewport width; an unstuck sticky
2007
+ header sitting mid-page stays out of the sum the same way. The ambient
2008
+ layer drops out by taking no pointer, the scroller's own pinned ancestors
2009
+ by containment: the sidebar is fixed and spans its nav's whole
2010
+ scrollport. */
2011
+ /* The stylesheets name which selectors CAN pin, so the candidates come from one
2012
+ `querySelectorAll` rather than a computed read of every element: that read
2013
+ forces rendering inside any `content-visibility` subtree it crosses, and this
2014
+ system's collapsed rows all carry one. Selectors are proved usable once,
2015
+ because one bad one poisons the whole list. The list itself is static, since
2016
+ stylesheets do not change; the VERDICTS are not, so they are re-read every
2017
+ time. An earlier version cached them per interaction burst on the claim that
2018
+ an overlay can only mount between bursts. That claim was false: the move that
2019
+ carries a pointer to a control arms the timer, so the click that opens the
2020
+ overlay always lands inside a live burst, and the stale verdict shipped for up
2021
+ to a full idle period. The whole walk measures 0.37ms on the reference sheet
2022
+ (16 selectors, 18 matches, 9 kept), which buys nothing worth being wrong for.
2023
+
2024
+ One cover this can never name: a pseudo-element. `collectPropSelectors` drops
2025
+ any selector holding `::`, so the navbar's fixed `body::before` dock strip is
2026
+ outside the derivation by construction. It is subsumed today, since the
2027
+ navbar band above it already covers that strip's whole height, and the next
2028
+ such cover may not be. */
2029
+ const pinned = collectPropSelectors(document.styleSheets, 'position', ['fixed', 'sticky'])
2030
+ .filter(sel => { try { bars.y.matches(sel); return true; } catch { return false; } })
2031
+ .join(', ');
2032
+ const coverList = () => (pinned ? [...document.querySelectorAll(pinned)] : []).flatMap(c => {
2033
+ if (c.classList.contains('ui-scrollbar')) return [];
2034
+ const cs = getComputedStyle(c);
2035
+ if (cs.position !== 'fixed' && cs.position !== 'sticky') return [];
2036
+ if (cs.pointerEvents === 'none') return [];
2037
+ const z = parseInt(cs.zIndex, 10); // `auto` is NaN, which counts as 0
2038
+ if (z >= SCROLLBAR_Z) return []; // paints over the bar, so it hides the bar
2039
+ return [{ el: c, z: Number.isNaN(z) ? 0 : z }];
2040
+ });
2041
+
2042
+ /* The scroller's own paint slot. A z-index orders siblings inside ONE stacking
2043
+ context, so "is this candidate below the bar" answers the wrong question: it
2044
+ says whether a cover hides the BAR, never whether it hides the SCROLLER. The
2045
+ drawer's scrim sits at 299 and the drawer itself at 300, so the scrim was
2046
+ charged against the drawer's own nav, which paints above it, and that nav lost
2047
+ its bar entirely at exactly the moment it is on screen. */
2048
+ const floorOf = el => {
2049
+ for (let n = el; n instanceof Element; n = n.parentElement) {
2050
+ const cs = getComputedStyle(n);
2051
+ if (cs.position === 'static') continue;
2052
+ const z = parseInt(cs.zIndex, 10);
2053
+ if (!Number.isNaN(z)) return z;
2054
+ }
2055
+ return 0;
2056
+ };
2057
+
2058
+ const cover = (el, axis) => {
2059
+ const box = boxOf(el);
2060
+ const lane = axis === 'y' ? [box.right - SCROLLBAR_LANE, box.right - SCROLLBAR_GAP]
2061
+ : [box.bottom - SCROLLBAR_LANE, box.bottom - SCROLLBAR_GAP];
2062
+ const spans = [];
2063
+ /* `checkVisibility` is guarded because this runs inside an rAF callback, and a
2064
+ TypeError there reaches the ring's `window.onerror` bail, which drops the
2065
+ hidden cursor for the life of the page: a missing method would cost the
2066
+ whole ambient layer, not the bar. Every other newer capability in this
2067
+ system is guarded the same way. `getClientRects().length` is the fallback,
2068
+ which answers the same question for a `display: none` subtree. */
2069
+ const shown = c => (typeof c.checkVisibility === 'function'
2070
+ ? c.checkVisibility() : c.getClientRects().length > 0);
2071
+ const floor = floorOf(el);
2072
+ for (const { el: c, z } of coverList()) {
2073
+ if (c === el || c.contains(el) || z < floor || !shown(c)) continue;
2074
+ const r = c.getBoundingClientRect();
2075
+ if (!r.width || !r.height) continue;
2076
+ const across = axis === 'y' ? r.left < lane[1] && r.right > lane[0]
2077
+ : r.top < lane[1] && r.bottom > lane[0];
2078
+ if (across) spans.push(axis === 'y' ? [r.top, r.bottom] : [r.left, r.right]);
2079
+ }
2080
+ const [lo, hi] = axis === 'y' ? [box.top, box.bottom] : [box.left, box.right];
2081
+ // each pass admits the covers the previous one brought within reach
2082
+ const run = (from, forward) => {
2083
+ for (let pass = 0; pass < spans.length; pass++) {
2084
+ let next = from;
2085
+ for (const [a, b] of spans) {
2086
+ if (forward) { if (a <= from + SCROLLBAR_CHAIN && b > next) next = b; }
2087
+ else if (b >= from - SCROLLBAR_CHAIN && a < next) next = a;
2088
+ }
2089
+ if (next === from) return from;
2090
+ from = next;
2091
+ }
2092
+ return from;
2093
+ };
2094
+ return [Math.max(0, run(lo, true) - lo), Math.max(0, hi - run(hi, false))];
2095
+ };
2096
+
2097
+ /* One place derives the thumb, because the drag's scroll rate has to agree
2098
+ with it: a rate taken from a different track length drifts away from the
2099
+ pointer over the length of the drag. */
2100
+ const metrics = (el, axis) => {
2101
+ const v = view(el, axis);
2102
+ const [cornerA, cornerB] = corner(el, axis);
2103
+ /* A DRAG FREEZES ITS OWN TRACK. `drag.rate` is captured at the press, and the
2104
+ cover is live, so chrome that docks or sticks mid-drag re-derives `track`
2105
+ under a rate that cannot follow it and the thumb slips against the pointer.
2106
+ Measured on the playground, one drag: the grab offset ran 49.72 to -5.33 to
2107
+ 109.17, a range of 114.5px on a 99.4px thumb, and the 48.0px of it owed to
2108
+ the navbar docking was predicted from `track - len` 800.55 to 744.55 before
2109
+ it was measured. Freezing is also the honest reading: the track a hand is
2110
+ holding does not change length under it. */
2111
+ const [coverA, coverB] = (drag && drag.axis === axis && drag.el === el)
2112
+ ? drag.pads : cover(el, axis);
2113
+ const padA = Math.max(cornerA, coverA), padB = Math.max(cornerB, coverB);
2114
+ const room = v - padA - padB;
2115
+ const track = Math.max(SCROLLBAR_MIN, room);
2116
+ const len = Math.min(track, Math.max(SCROLLBAR_MIN, v * v / full(el, axis)));
2117
+ return { padA, track, len, room, max: full(el, axis) - v };
2118
+ };
2119
+
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 };
2128
+ let hideAt = 0;
2129
+ let drag = null;
2130
+ let frame = 0;
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
+ };
2139
+
2140
+ const place = axis => {
2141
+ const bar = bars[axis];
2142
+ const el = drag && drag.axis === axis ? drag.el : active[axis];
2143
+ if (!el.isConnected || !scrolls(el, axis)) { bar.classList.remove('is-on'); return; }
2144
+ const box = boxOf(el);
2145
+ const { padA, track, len, room, max } = metrics(el, axis);
2146
+ // pinned chrome can take the whole scrollport, and a track with no room for
2147
+ // a handle is a mark, not a bar
2148
+ if (room < SCROLLBAR_MIN) { bar.classList.remove('is-on'); return; }
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;
2166
+ if (axis === 'y') {
2167
+ bar.style.top = `${box.top + run}px`;
2168
+ bar.style.left = `${box.right - SCROLLBAR_LANE}px`;
2169
+ bar.style.height = `${len}px`;
2170
+ } else {
2171
+ bar.style.left = `${box.left + run}px`;
2172
+ bar.style.top = `${box.bottom - SCROLLBAR_LANE}px`;
2173
+ bar.style.width = `${len}px`;
2174
+ }
2175
+ // the thumb's own box, padded: a bar that retired under a pointer reaching
2176
+ // for it would take the grab with it
2177
+ const near = pointer && (axis === 'y'
2178
+ ? pointer.x >= box.right - SCROLLBAR_LANE - SCROLLBAR_HOLD && pointer.x <= box.right - SCROLLBAR_GAP + SCROLLBAR_HOLD
2179
+ && pointer.y >= box.top + run - SCROLLBAR_HOLD && pointer.y <= box.top + run + len + SCROLLBAR_HOLD
2180
+ : pointer.y >= box.bottom - SCROLLBAR_LANE - SCROLLBAR_HOLD && pointer.y <= box.bottom - SCROLLBAR_GAP + SCROLLBAR_HOLD
2181
+ && pointer.x >= box.left + run - SCROLLBAR_HOLD && pointer.x <= box.left + run + len + SCROLLBAR_HOLD);
2182
+ const on = drag ? drag.axis === axis : Boolean(near) || performance.now() < hideAt;
2183
+ const wasOn = bar.classList.contains('is-on');
2184
+ bar.classList.toggle('is-on', on);
2185
+ /* THE MOVE THAT ARMS A RETIRED BAR is hit-tested while the bar still
2186
+ computes `pointer-events: none`, so `e.target` named the page and the
2187
+ ring derived nothing it can hover; a press with no further move then
2188
+ read no hover and painted neither the pressed costume nor the release
2189
+ pulse. The bar is what a real move would hit now that it takes a
2190
+ pointer, so the pointermove path is re-run with the BAR as the target.
2191
+ The bubbled event reaches four handlers, all traced: this controller's
2192
+ own move handler, which leaves `active` alone for a bar target; the
2193
+ bar's drag handler, inert without a held drag; and cursor.js's grid and
2194
+ ring handlers, of which only the ring's state moves, to `hover`.
2195
+ Dispatching on window instead would re-derive the scroller off
2196
+ `document` and hand the bar to the page. */
2197
+ if (on && !wasOn && !drag && pointer
2198
+ && document.elementFromPoint(pointer.x, pointer.y) === bar) {
2199
+ bar.dispatchEvent(new PointerEvent('pointermove',
2200
+ { pointerType: 'mouse', clientX: pointer.x, clientY: pointer.y, bubbles: true }));
2201
+ }
2202
+ };
2203
+
2204
+ /* The loop runs only while something is actually moving: a drag, or the tail of
2205
+ a scroll or a pointer move. A bar held on because the pointer rests on the
2206
+ thumb needs no frames, and `place` has already run for that state; the next
2207
+ pointermove is what changes it. An rAF that never stops is ambient motion
2208
+ with a battery cost, which this system bans everywhere else. */
2209
+ const tick = () => {
2210
+ frame = 0;
2211
+ place('y');
2212
+ place('x');
2213
+ if (drag || performance.now() < hideAt) sync();
2214
+ };
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(); };
2218
+
2219
+ addEventListener('scroll', e => {
2220
+ const el = e.target === document || e.target === root ? root : e.target;
2221
+ if (!(el instanceof Element) && el !== root) return;
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();
2228
+ }, { capture: true, passive: true });
2229
+
2230
+ addEventListener('pointermove', e => {
2231
+ pointer = { x: e.clientX, y: e.clientY };
2232
+ /* THE BAR IS ITS OWN POINTER TARGET while it is on, so a move onto the thumb
2233
+ makes `e.target` the bar and `scrollerFrom` walks bar -> body -> the root,
2234
+ which scrolls. Measured on the reference sheet: the sidebar nav's thumb sat
2235
+ at left 243 and jumped to 1432 on the first move that reached it, and the
2236
+ press that then landed dragged the page. No inner scroller's bar could be
2237
+ grabbed, and the native one is already hidden. A move ON the bar says
2238
+ nothing about which scroller is under the pointer: the bar belongs to
2239
+ `active` by construction, so leave it alone. */
2240
+ const onBar = e.target instanceof Element && e.target.classList.contains('ui-scrollbar');
2241
+ if (!drag && !onBar) {
2242
+ active.x = scrollerFrom(e.target, 'x');
2243
+ active.y = scrollerFrom(e.target, 'y');
2244
+ }
2245
+ // the move is what arms the timer: a resting pointer retires the bar
2246
+ arm();
2247
+ }, { passive: true });
2248
+
2249
+ /* `pointer` was write-only, and the Y bar's hold band runs to `innerWidth + 8`,
2250
+ so a pointer leaving the window to the right exits THROUGH it and its last
2251
+ move is inside it: the bar then stayed lit for the life of the page. Measured
2252
+ parked at x 1439, still on after 3s. The ring's own host does the same thing
2253
+ for the same reason. */
2254
+ document.documentElement.addEventListener('pointerleave', () => { pointer = null; sync(); });
2255
+
2256
+ addEventListener('resize', () => { radii = new WeakMap(); anchors = new WeakMap(); sync(); });
2257
+
2258
+ for (const axis of ['y', 'x']) {
2259
+ bars[axis].addEventListener('pointerdown', e => {
2260
+ const el = active[axis];
2261
+ if (!scrolls(el, axis)) return;
2262
+ if (e.pointerType === 'mouse' && e.button !== 0) return; // middle and right are not a drag
2263
+ if (drag) return; // one bar at a time: a second press would
2264
+ e.preventDefault(); // orphan the first bar's capture and is-drag
2265
+ const m = metrics(el, axis);
2266
+ // a track with no room for a handle cannot be dragged: without this the
2267
+ // `Math.max(SCROLLBAR_MIN, room)` clamp makes `track - len` 0 and one pixel
2268
+ // of travel scrolls the whole document
2269
+ if (m.room < SCROLLBAR_MIN) return;
2270
+ bars[axis].setPointerCapture(e.pointerId);
2271
+ bars[axis].classList.add('is-drag');
2272
+ drag = {
2273
+ axis, el,
2274
+ pads: cover(el, axis), // frozen for the drag, see `metrics`
2275
+ from: at(el, axis),
2276
+ origin: axis === 'y' ? e.clientY : e.clientX,
2277
+ // one pixel of thumb travel is this much scroll
2278
+ rate: m.max / Math.max(1, m.track - m.len),
2279
+ };
2280
+ sync();
2281
+ });
2282
+ bars[axis].addEventListener('pointermove', e => {
2283
+ if (!drag || drag.axis !== axis) return;
2284
+ const now = axis === 'y' ? e.clientY : e.clientX;
2285
+ const to = drag.from + (now - drag.origin) * drag.rate;
2286
+ if (axis === 'y') drag.el.scrollTop = to; else drag.el.scrollLeft = to;
2287
+ arm();
2288
+ });
2289
+ const end = () => {
2290
+ if (!drag || drag.axis !== axis) return;
2291
+ drag = null;
2292
+ bars[axis].classList.remove('is-drag');
2293
+ arm();
2294
+ };
2295
+ bars[axis].addEventListener('pointerup', end);
2296
+ bars[axis].addEventListener('pointercancel', end);
2297
+ }
2298
+
2299
+ sync();
2300
+ }
2301
+
2302
+ /* ============================= dropzone =========================== */
2303
+
2304
+ /* The native input stays the accessible control and the label keeps opening the
2305
+ picker, but the input no longer covers the zone: a stretched one paints the
2306
+ platform pointer straight through the ring's `cursor: none`, because its UA
2307
+ shadow tree is out of the page's reach. The zone takes the drop instead and
2308
+ hands the files over. Drag counting is by depth, since a dragleave fires for
2309
+ every child the pointer crosses. */
2310
+ export function initDropzone() {
2311
+ document.querySelectorAll('.dropzone').forEach(zone => {
2312
+ const input = zone.querySelector('.dropzone-input');
2313
+ if (!input || !once(zone, 'uiDropzone')) return;
2314
+ let depth = 0;
2315
+ const clear = () => { depth = 0; zone.classList.remove('is-over'); };
2316
+
2317
+ /* a disabled input takes no files from the keyboard, so the zone must not
2318
+ take them from the pointer either: refusing the dragenter and dragover
2319
+ defaults also keeps the browser from marking the dead zone a target */
2320
+ zone.addEventListener('dragenter', e => {
2321
+ if (input.disabled) return;
2322
+ e.preventDefault();
2323
+ if (++depth === 1) zone.classList.add('is-over');
2324
+ });
2325
+ zone.addEventListener('dragover', e => {
2326
+ if (input.disabled) return;
2327
+ e.preventDefault();
2328
+ });
2329
+ zone.addEventListener('dragleave', () => { if (--depth <= 0) clear(); });
2330
+ zone.addEventListener('drop', e => {
2331
+ if (input.disabled) { clear(); return; } // a drag that began live can end disabled
2332
+ e.preventDefault();
2333
+ clear();
2334
+ if (!e.dataTransfer?.files.length) return;
2335
+ input.files = e.dataTransfer.files;
2336
+ input.dispatchEvent(new Event('change', { bubbles: true }));
2337
+ });
2338
+ });
2339
+ }
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
+
2455
+ /* ============================== boot ============================== */
2456
+
2457
+ export function init() {
2458
+ initViewportWidth();
2459
+ initTabs();
2460
+ initNavbar();
2461
+ initSidebar();
2462
+ initDrawer();
2463
+ initToggles();
2464
+ initDisclosures();
2465
+ initSelect();
2466
+ initSlider();
2467
+ initPalette();
2468
+ initTooltip();
2469
+ initPopovers();
2470
+ initModal();
2471
+ initDismiss();
2472
+ initTheme();
2473
+ initSticky();
2474
+ initReveal();
2475
+ initChart();
2476
+ initCardButtons();
2477
+ initTables();
2478
+ initCodeCopy();
2479
+ initTypedPlaceholder();
2480
+ initScrollFade();
2481
+ initScrollbars();
2482
+ initDropzone();
2483
+ initStepper();
2484
+ }
2485
+
2486
+ // a module script is deferred, so the document is parsed by the time this runs
2487
+ init();