reviewzy 0.2.0 → 0.3.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,1992 @@
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
+ /* ====================== sliding inks (tabs, navbar) ====================== */
40
+
41
+ /* First placement lands in place: with the transform transition live, an ink
42
+ fading in would slide from x=0 behind its own fade, reading as a teleport.
43
+ Kill the transition once, flush, restore.
44
+
45
+ An ink declaring --ink-base (.tab-ink, .navbar-ink) is laid out at that width
46
+ and scaled, so its length rides the same transform as its travel and cannot
47
+ snap to the target while the bar is still moving. The boxed pill has no
48
+ --ink-base: scaleX would distort its 11px corners, so it resizes instead.
49
+
50
+ Both ride translate3d: the z holds the ink's layer for the life of the page,
51
+ so the sub-pixel geometry painted during the slide is the same geometry
52
+ painted after it. A layer dropped at transitionend re-rasterizes the
53
+ fractional size and position at whole pixels, which is the visible snap
54
+ after landing. */
55
+ export function placeInk(inkEl, width, x) {
56
+ const first = !inkEl.dataset.placed;
57
+ if (first) {
58
+ inkEl.dataset.placed = '1';
59
+ inkEl.style.transition = 'none';
60
+ }
61
+ const base = parseFloat(getComputedStyle(inkEl).getPropertyValue('--ink-base'));
62
+ const next = base > 0
63
+ ? `translate3d(${x}px, 0, 0) scaleX(${width / base})`
64
+ : `translate3d(${x}px, 0, 0)`;
65
+ if (!(base > 0)) inkEl.style.width = width + 'px';
66
+ inkEl.style.transform = next;
67
+ if (first) {
68
+ inkEl.getBoundingClientRect(); // flush: the jump happens un-animated
69
+ requestAnimationFrame(() => { inkEl.style.transition = ''; });
70
+ }
71
+ inkEl.style.opacity = '1';
72
+ }
73
+
74
+ // one geometry read for every ink on the page: the active child's box against its rail
75
+ function placeFrom(wrapEl, inkEl, activeSel) {
76
+ if (!wrapEl || !inkEl) return;
77
+ const active = wrapEl.querySelector(activeSel);
78
+ if (!active) { inkEl.style.opacity = '0'; return; }
79
+ const wRect = wrapEl.getBoundingClientRect();
80
+ const aRect = active.getBoundingClientRect();
81
+ placeInk(inkEl, aRect.width, aRect.left - wRect.left);
82
+ }
83
+
84
+ /* A MARKER A CONSUMER HAS TO WRITE IS THE COMPONENT DEFINED TWICE, and the copy
85
+ that gets forgotten is the one a reader meets. Four of them used to be markup:
86
+ `.tab-ink`, `.tabs-boxed-ink`, `.navbar-ink` and the sidebar row's `.nav-dot`,
87
+ 61 hand-written spans on the pages in this repo alone. They are built here, so
88
+ a page writes the wrap and gets the whole component. Where a marker sits is
89
+ part of it: these are positioned with no z-index, so paint order is document
90
+ order, and one that paints UNDER its siblings goes first. Existing markup is
91
+ reused, so a page that still writes one never gets a second. */
92
+ function ownChild(parent, cls, { tag = 'div', first = false } = {}) {
93
+ let el = parent.querySelector(`:scope > .${cls}`);
94
+ if (!el) {
95
+ el = document.createElement(tag);
96
+ el.className = cls;
97
+ el.setAttribute('aria-hidden', 'true');
98
+ if (first) parent.prepend(el); else parent.append(el);
99
+ }
100
+ return el;
101
+ }
102
+
103
+ /* Same reading, one step further: a marker is not the only internal a consumer can
104
+ be left to write. A table header's label needs a box of its own to centre in,
105
+ because a cell centres its LINE BOX and all-caps ink rides high in one. The
106
+ measurement and the levers that do not work are in `components.css` beside
107
+ `.th-label`. This one carries the column's accessible name, so unlike `ownChild`
108
+ it is never `aria-hidden`. It wraps the cell's text into one span placed where
109
+ the text starts, which is why it REFUSES a cell whose text is split by an
110
+ element: joining `P50 <span class=tag>ms</span> avg` would move the tag to the
111
+ end and read the column out as `P50 avg ms`. Nothing on disk writes that shape,
112
+ and a design system is consumed by markup nobody here wrote. The shapes it does
113
+ take are a sort button (text, then the glyph) and a plain cell; the select-all
114
+ cell holds a checkbox and no text, so it is left alone. */
115
+ function ownLabel(parent, cls) {
116
+ const have = parent.querySelector(`:scope > .${cls}`);
117
+ if (have) return have;
118
+ const kids = [...parent.childNodes];
119
+ const texts = kids.filter(n => n.nodeType === 3 && n.nodeValue.trim());
120
+ if (!texts.length) return null;
121
+ const span = kids.slice(kids.indexOf(texts[0]), kids.indexOf(texts[texts.length - 1]) + 1);
122
+ if (span.some(n => n.nodeType === 1)) return null; // an element splits the text
123
+ const el = document.createElement('span');
124
+ el.className = cls;
125
+ el.textContent = texts.map(n => n.nodeValue).join('').trim();
126
+ texts.slice(1).forEach(n => n.remove());
127
+ parent.replaceChild(el, texts[0]);
128
+ return el;
129
+ }
130
+
131
+ // a resize or a scroll moves the ink without animating the slide: the indicator is
132
+ // following its tab, not travelling to a new one
133
+ function replace(inkEl, run) {
134
+ if (!inkEl) return;
135
+ inkEl.style.transition = 'none';
136
+ run();
137
+ requestAnimationFrame(() => { inkEl.style.transition = ''; });
138
+ }
139
+
140
+ /* ============================== tabs ============================== */
141
+
142
+ /* Underline tabs with a sliding ink, and boxed segments with a sliding pill.
143
+
144
+ `data-tabs="group-id"` groups tabs that live in different wraps and `data-panel`
145
+ names each one's panel. BOTH ARE OPTIONAL: with neither, the wrap is the group
146
+ and the control just moves its own marker, which is what a segmented control
147
+ with no panels needs. Keying only on `data-tabs` is why every page carrying a
148
+ panel-less boxed control had a hand-written copy of this. */
149
+ export function initTabs() {
150
+ /* examples/dashboard.html is what this cost: its wrap held no pill, so the
151
+ segments cross-faded their own backgrounds instead. The two read identical
152
+ AT REST, which is why no screenshot could fail on it; measured 60ms into a
153
+ click, the sheet's pill at translate 24.55 on its way to 42.42 against the
154
+ dashboard's nothing. The boxed pill paints under its segments, so it goes
155
+ first. */
156
+ const inkFor = wrap => wrap && (wrap.classList.contains('tabs-boxed-wrap')
157
+ ? ownChild(wrap, 'tabs-boxed-ink', { first: true })
158
+ : ownChild(wrap, 'tab-ink'));
159
+ const wraps = () => document.querySelectorAll('.tabs-wrap, .tabs-boxed-wrap');
160
+ const placeWrap = wrap => {
161
+ const ink = inkFor(wrap);
162
+ if (!ink) return;
163
+ const boxed = ink.classList.contains('tabs-boxed-ink');
164
+ placeFrom(wrap, ink, boxed ? '.tab-box.active' : '.tab.active');
165
+ if (boxed) wrap.classList.add('has-ink');
166
+ };
167
+
168
+ document.querySelectorAll('.tab, .tab-box').forEach(btn => {
169
+ if (!once(btn, 'uiTab')) return;
170
+ const wrap = btn.closest('.tabs-wrap, .tabs-boxed-wrap');
171
+ const kind = btn.classList.contains('tab-box') ? '.tab-box' : '.tab';
172
+ btn.addEventListener('click', () => {
173
+ const group = btn.dataset.tabs;
174
+ const peers = group
175
+ ? [...document.querySelectorAll(`[data-tabs="${group}"]`)]
176
+ : [...(wrap ? wrap.querySelectorAll(kind) : [btn])];
177
+ peers.forEach(b => b.classList.remove('active'));
178
+ btn.classList.add('active');
179
+
180
+ new Set(peers.map(b => b.dataset.panel)).forEach(id => {
181
+ const el = id && document.getElementById(id);
182
+ if (el) el.classList.remove('active');
183
+ });
184
+ const panel = btn.dataset.panel && document.getElementById(btn.dataset.panel);
185
+ if (panel) panel.classList.add('active');
186
+
187
+ // a data-tabs group can span wraps, so every wrap holding a peer re-places
188
+ new Set(peers.map(b => b.closest('.tabs-wrap, .tabs-boxed-wrap')).filter(Boolean))
189
+ .forEach(placeWrap);
190
+ });
191
+ });
192
+
193
+ requestAnimationFrame(() => wraps().forEach(placeWrap)); // first placement, after layout
194
+ if (once(document.body, 'uiTabsResize')) {
195
+ // a resize moves the ink without animating the slide: it is following its tab,
196
+ // not travelling to a new one
197
+ addEventListener('resize', () => wraps().forEach(w => replace(inkFor(w), () => placeWrap(w))));
198
+ }
199
+ }
200
+
201
+ /* ============================= navbar ============================= */
202
+
203
+ /* The tab ink's physics on the horizontal navbar, plus the bar's docking state.
204
+
205
+ Every `.navbar-nav` holding a `.navbar-ink` is wired, and each one owns only its
206
+ own links: a page carrying a second navbar (a narrow variant, a docs sub-bar)
207
+ used to need a hand-written copy of this controller, and the copy drifted.
208
+ Nothing here is required to exist; a page with no navbar wires nothing rather
209
+ than throwing on a null lookup, which is what the pasted version did. */
210
+ export function initNavbar() {
211
+ document.querySelectorAll('.navbar-nav').forEach(nav => {
212
+ if (!once(nav, 'uiNavbar')) return;
213
+ const ink = ownChild(nav, 'navbar-ink');
214
+ const place = () => placeFrom(nav, ink, '.navbar-link.active');
215
+
216
+ nav.querySelectorAll('.navbar-link').forEach(link => {
217
+ link.addEventListener('click', e => {
218
+ if (link.getAttribute('href') === '#') e.preventDefault();
219
+ nav.querySelectorAll('.navbar-link').forEach(l => l.classList.remove('active'));
220
+ link.classList.add('active');
221
+ place();
222
+ });
223
+ });
224
+
225
+ addEventListener('resize', () => replace(ink, place));
226
+ // under 768px the band scrolls and the ink rides the content: the same contract
227
+ nav.addEventListener('scroll', () => replace(ink, place), { passive: true });
228
+ requestAnimationFrame(place);
229
+ });
230
+
231
+ /* The docking state. scrollY is the whole state: `.is-top` drops the moment the
232
+ page moves and returns at the top, so the bar is a floating card at rest and a
233
+ band once scrolled. Below 768px the CSS ignores it. */
234
+ document.querySelectorAll('.navbar').forEach(bar => {
235
+ if (!once(bar, 'uiNavbarDock')) return;
236
+ const sync = () => bar.classList.toggle('is-top', scrollY <= 0);
237
+ sync();
238
+ addEventListener('scroll', sync, { passive: true });
239
+ });
240
+ }
241
+
242
+ /* ============================= sidebar ============================ */
243
+
244
+ /* The marker is the active row's own dot, so nothing is measured or positioned:
245
+ the controller moves one class and the CSS does the rest.
246
+
247
+ Every `.nav-item` is wired, each owning only the rows in its own sidebar.
248
+ Keying on `.nav-item[data-section]` left a plain sidebar inert, so the worked
249
+ dashboard example carried a hand-written copy of these six lines. */
250
+ export function initSidebar() {
251
+ document.querySelectorAll('.nav-item').forEach(item => {
252
+ if (!once(item, 'uiNav')) return;
253
+ // the row's own dot is the marker, so it belongs to the row, not to the page
254
+ ownChild(item, 'nav-dot', { tag: 'span', first: true });
255
+ const scope = item.closest('.sidebar') || document;
256
+ item.addEventListener('click', () => {
257
+ scope.querySelectorAll('.nav-item').forEach(n => {
258
+ n.classList.remove('active');
259
+ n.removeAttribute('aria-current');
260
+ });
261
+ item.classList.add('active');
262
+ item.setAttribute('aria-current', 'true');
263
+ });
264
+ });
265
+
266
+ // the button is optional, and its own sidebar is the one it folds
267
+ document.querySelectorAll('.sidebar-collapse').forEach(collapse => {
268
+ const column = collapse.closest('.sidebar');
269
+ if (!column || !once(collapse, 'uiCollapse')) return;
270
+ const sync = () => {
271
+ const folded = column.classList.contains('rail');
272
+ collapse.setAttribute('aria-expanded', String(!folded));
273
+ collapse.setAttribute('aria-label', folded ? 'Expand sidebar' : 'Collapse sidebar');
274
+ };
275
+ sync(); // a sidebar may ship folded
276
+ collapse.addEventListener('click', () => { column.classList.toggle('rail'); sync(); });
277
+ });
278
+ }
279
+
280
+ /* ========================== mobile drawer ========================= */
281
+
282
+ export function initDrawer() {
283
+ const sidebar = document.getElementById('sidebar');
284
+ const overlay = document.getElementById('sidebar-overlay');
285
+ const hamburger = document.getElementById('hamburger-btn');
286
+ if (!sidebar) return;
287
+
288
+ const setOpen = open => {
289
+ sidebar.classList.toggle('open', open);
290
+ overlay?.classList.toggle('open', open);
291
+ hamburger?.setAttribute('aria-expanded', String(open));
292
+ };
293
+ if (hamburger && once(hamburger, 'uiDrawer')) hamburger.addEventListener('click', () => setOpen(true));
294
+ if (overlay && once(overlay, 'uiDrawer')) overlay.addEventListener('click', () => setOpen(false));
295
+ if (once(sidebar, 'uiDrawerRows')) {
296
+ document.querySelectorAll('.nav-item, .navbar-link').forEach(item => {
297
+ item.addEventListener('click', () => { if (innerWidth <= 768) setOpen(false); });
298
+ });
299
+ addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); });
300
+ }
301
+ }
302
+
303
+ /* ====================== switches and their rows ====================== */
304
+
305
+ /* A `.toggle` is a real button carrying `aria-pressed`, so the state and the
306
+ semantics move together and never disagree.
307
+
308
+ THE ROW IS THE TARGET, her call 2026-08-29: a coarse pointer gets its 44px on
309
+ the row and the control keeps its proportions, so the row has to be clickable
310
+ or that floor buys nothing. The guard skips a click that landed on another
311
+ control, so the control's own handler runs once and the row cannot double-toggle
312
+ it. Every page carrying a settings panel had written this by hand. */
313
+ export function initToggles() {
314
+ const isControl = t => t.closest?.('button, a, input, select, textarea, label');
315
+
316
+ document.querySelectorAll('.toggle').forEach(t => {
317
+ if (t.disabled || !once(t, 'uiToggle')) return;
318
+ t.addEventListener('click', () => {
319
+ t.setAttribute('aria-pressed', String(t.classList.toggle('on')));
320
+ });
321
+ });
322
+
323
+ document.querySelectorAll('.toggle-wrap, .setting-row').forEach(row => {
324
+ if (!once(row, 'uiRowTarget')) return;
325
+ row.addEventListener('click', e => {
326
+ if (isControl(e.target)) return;
327
+ row.querySelector('.toggle:not(:disabled)')?.click();
328
+ });
329
+ });
330
+ }
331
+
332
+ /* ============================ disclosure ========================== */
333
+
334
+ /* `details` has no disabled state, so a row that cannot act says so with
335
+ aria-disabled and the summary stays focusable to announce why. Its click IS
336
+ the toggle (keyboard Enter and Space ride the same event), so cancelling the
337
+ click cancels the row; the costume lives in components.css beside the
338
+ accordion. */
339
+ export function initDisclosures() {
340
+ document.querySelectorAll('.disclosure[aria-disabled="true"] > summary').forEach(s => {
341
+ if (!once(s, 'uiDisclosure')) return;
342
+ s.addEventListener('click', e => e.preventDefault());
343
+ });
344
+ }
345
+
346
+ /* ============================== select ============================ */
347
+
348
+ /* The trigger keeps DOM focus for the whole interaction and the panel is a listbox,
349
+ so the arrow-key row is named by aria-activedescendant instead of being focused.
350
+ Escape therefore always lands back on the control that opened. */
351
+ const selectControllers = [];
352
+
353
+ export function initSelect() {
354
+ document.querySelectorAll('.select').forEach((root, i) => {
355
+ const trigger = root.querySelector('.select-trigger');
356
+ const menu = root.querySelector('.select-menu');
357
+ const options = [...root.querySelectorAll('[role="option"]')];
358
+ const empty = root.querySelector('.palette-empty');
359
+
360
+ /* A div trigger stays focusable under aria-disabled (that is the pattern:
361
+ it can announce why it cannot act), and an input's disabled state stops its
362
+ own events but not a clear sitting beside it, so every activation path
363
+ reads the state here rather than trusting the pointer's `none`. It has to
364
+ exist before the adapter builds: the multi adapter wires chip listeners at
365
+ construction, and a root the guards below return early for (its menu not
366
+ rendered yet) would leave this in its dead zone and throw at the first
367
+ chip click. */
368
+ const dead = () => trigger.disabled || trigger.getAttribute('aria-disabled') === 'true';
369
+
370
+ // one value surface per shape: the shipped .select-value, an <input>
371
+ // trigger's value (combobox), or the chips in a multi-select trigger
372
+ const valueAdapter = valueEl => valueEl && ({
373
+ read: () => valueEl.textContent.trim(),
374
+ value: () => valueEl.textContent.trim(),
375
+ pick(opt) {
376
+ options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
377
+ valueEl.textContent = opt.textContent.trim();
378
+ return true;
379
+ },
380
+ clear() {},
381
+ });
382
+ const comboAdapter = (root, trigger) => {
383
+ let committed = trigger.value.trim();
384
+ const showAll = () => { options.forEach(o => { o.hidden = false; }); if (empty) empty.hidden = true; };
385
+ return {
386
+ read: () => committed,
387
+ value: () => committed,
388
+ // every open starts from the committed value with the full list, so a
389
+ // query a blur left behind cannot pin the panel to one match
390
+ onOpen: () => { trigger.value = committed; showAll(); },
391
+ pick(opt) {
392
+ options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
393
+ committed = opt.textContent.trim();
394
+ trigger.value = committed;
395
+ return true;
396
+ },
397
+ clear: () => { committed = ''; trigger.value = ''; showAll(); },
398
+ restore: () => { trigger.value = committed; showAll(); },
399
+ };
400
+ };
401
+ const multiAdapter = (root, trigger) => {
402
+ const chips = root.querySelector('.select-chips');
403
+ const picked = () => options.filter(o => o.getAttribute('aria-selected') === 'true')
404
+ .map(o => o.textContent.trim()).join(', ');
405
+ const sync = () => { root.dataset.value = picked(); };
406
+ const announce = () => root.dispatchEvent(
407
+ new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
408
+ const removeChip = chip => {
409
+ if (dead()) return;
410
+ const label = chip.querySelector('span')?.textContent ?? '';
411
+ const opt = options.find(o => o.textContent.trim() === label);
412
+ if (opt) opt.setAttribute('aria-selected', 'false');
413
+ chip.remove();
414
+ sync();
415
+ announce();
416
+ };
417
+ // one wiring path for a chip whatever created it: the root's once mark
418
+ // never covers one, since construction wires before that guard runs
419
+ const wireChip = chip => {
420
+ if (!once(chip, 'uiSelectChip')) return;
421
+ chip.addEventListener('click', e => { e.stopPropagation(); removeChip(chip); });
422
+ };
423
+ const addChip = label => {
424
+ if (!chips) return;
425
+ const chip = document.createElement('button');
426
+ chip.type = 'button';
427
+ chip.className = 'tag filter-chip select-chip';
428
+ chip.setAttribute('aria-label', `remove ${label}`);
429
+ chip.innerHTML = '<span></span><svg class="icon close" aria-hidden="true"><use href="#i-x"/></svg>';
430
+ chip.querySelector('span').textContent = label;
431
+ wireChip(chip);
432
+ chips.appendChild(chip);
433
+ };
434
+ // the fold looks like a chip, but it is authored summary text: wiring
435
+ // it as removable deletes the indicator on the click that should open
436
+ chips?.querySelectorAll('.select-chip:not(.select-chip-more)').forEach(wireChip);
437
+ return {
438
+ read: picked,
439
+ value: () => root.dataset.value,
440
+ pick(opt) {
441
+ const on = opt.getAttribute('aria-selected') === 'true';
442
+ opt.setAttribute('aria-selected', String(!on));
443
+ const label = opt.textContent.trim();
444
+ if (on) {
445
+ const chip = [...(chips?.querySelectorAll('.select-chip') ?? [])]
446
+ .find(c => c.querySelector('span')?.textContent === label);
447
+ chip?.remove();
448
+ } else {
449
+ addChip(label);
450
+ }
451
+ sync();
452
+ return false; // aria-multiselectable: a toggle does not close the panel
453
+ },
454
+ clear() {
455
+ options.forEach(o => o.setAttribute('aria-selected', 'false'));
456
+ chips?.querySelectorAll('.select-chip').forEach(c => c.remove());
457
+ sync();
458
+ },
459
+ };
460
+ };
461
+
462
+ const adapter = !trigger ? null
463
+ : root.classList.contains('select-multi') ? multiAdapter(root, trigger)
464
+ : trigger.tagName === 'INPUT' ? comboAdapter(root, trigger)
465
+ : valueAdapter(root.querySelector('.select-value'));
466
+ if (!trigger || !menu || !options.length || !adapter) return;
467
+ if (!once(root, 'uiSelect')) return;
468
+
469
+ menu.id ||= `select-${i}-menu`;
470
+ trigger.setAttribute('aria-controls', menu.id);
471
+ options.forEach((o, j) => { o.id ||= `select-${i}-opt-${j}`; });
472
+
473
+ const visible = () => options.filter(o => !o.hidden);
474
+ const selectedVisible = () => {
475
+ const vis = visible();
476
+ const at = vis.findIndex(o => o.getAttribute('aria-selected') === 'true');
477
+ return at >= 0 ? at : 0;
478
+ };
479
+ let focusIdx = selectedVisible();
480
+ // the value surface exists from load, or a consumer reading it before the
481
+ // first pick disagrees with what aria-selected already announces
482
+ root.dataset.value = adapter.read();
483
+ let closingTimer = 0;
484
+ const isOpen = () => root.classList.contains('open');
485
+ // read off --fold, the move itself, so a token change or reduced motion
486
+ // retimes the hold without touching this number
487
+ const exitMs = () => transMs(root);
488
+
489
+ // keeps the arrow-key row inside the scroll box without scrollIntoView,
490
+ // which would also scroll the page when the panel sits near an edge
491
+ const revealFocused = opt => {
492
+ const top = opt.offsetTop;
493
+ const bottom = top + opt.offsetHeight;
494
+ if (top < menu.scrollTop) menu.scrollTop = top;
495
+ else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
496
+ };
497
+
498
+ const markFocus = idx => {
499
+ const vis = visible();
500
+ if (!vis.length) {
501
+ trigger.removeAttribute('aria-activedescendant');
502
+ options.forEach(o => o.classList.remove('focus'));
503
+ return;
504
+ }
505
+ focusIdx = ((idx % vis.length) + vis.length) % vis.length;
506
+ const opt = vis[focusIdx];
507
+ options.forEach(o => o.classList.toggle('focus', o === opt));
508
+ trigger.setAttribute('aria-activedescendant', opt.id);
509
+ revealFocused(opt);
510
+ };
511
+
512
+ const close = ({ focus = false } = {}) => {
513
+ if (!isOpen()) return;
514
+ root.classList.remove('open');
515
+ // .closing keeps the body's rules applying while --fold runs back to 1;
516
+ // it drops on a frame where every value already equals the closed one
517
+ root.classList.add('closing');
518
+ clearTimeout(closingTimer);
519
+ closingTimer = setTimeout(() => root.classList.remove('closing'), exitMs());
520
+ trigger.setAttribute('aria-expanded', 'false');
521
+ trigger.removeAttribute('aria-activedescendant');
522
+ options.forEach(o => o.classList.remove('focus'));
523
+ if (focus) trigger.focus();
524
+ // .select-up stays: dropping it mid-exit flips the panel across the trigger
525
+ };
526
+
527
+ const open = () => {
528
+ if (dead() || isOpen()) return;
529
+ clearTimeout(closingTimer);
530
+ root.classList.remove('closing');
531
+ selectControllers.forEach(c => { if (c.root !== root) c.close(); });
532
+ adapter.onOpen?.();
533
+ // the side with room wins; visibility:hidden still lays out, so this measures
534
+ const r = trigger.getBoundingClientRect();
535
+ const below = innerHeight - r.bottom;
536
+ root.classList.toggle('select-up', below < menu.offsetHeight + 8 && r.top > below);
537
+ root.classList.add('open');
538
+ trigger.setAttribute('aria-expanded', 'true');
539
+ markFocus(selectedVisible());
540
+ };
541
+
542
+ const pick = opt => {
543
+ if (!opt) return;
544
+ if (adapter.pick(opt)) close({ focus: true });
545
+ root.dataset.value = adapter.value();
546
+ root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
547
+ };
548
+
549
+ const filter = () => {
550
+ const typed = trigger.value.trim();
551
+ // every word has to land, the same rule the palette filters its rows by
552
+ const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
553
+ options.forEach(opt => {
554
+ const hay = opt.textContent.trim().toLowerCase();
555
+ opt.hidden = !words.every(w => hay.includes(w));
556
+ });
557
+ if (empty) empty.hidden = visible().length > 0;
558
+ markFocus(selectedVisible());
559
+ };
560
+
561
+ // an <input> trigger edits the query: focus opens, Space types instead of
562
+ // committing, and clicking never closes. A button or div trigger toggles.
563
+ if (trigger.tagName === 'INPUT') {
564
+ trigger.addEventListener('focus', open);
565
+ trigger.addEventListener('input', filter);
566
+ }
567
+ trigger.addEventListener('click', () => {
568
+ if (trigger.tagName === 'INPUT') open();
569
+ else isOpen() ? close() : open();
570
+ });
571
+ trigger.addEventListener('blur', () => close());
572
+ trigger.addEventListener('keydown', e => {
573
+ // a key on a chip or the clear belongs to that control: the trigger's
574
+ // own handling would swallow the activation it should not answer for
575
+ if (e.target !== trigger) return;
576
+ switch (e.key) {
577
+ case 'ArrowDown': e.preventDefault(); isOpen() ? markFocus(focusIdx + 1) : open(); break;
578
+ case 'ArrowUp': e.preventDefault(); isOpen() ? markFocus(focusIdx - 1) : open(); break;
579
+ case 'Home': if (isOpen()) { e.preventDefault(); markFocus(0); } break;
580
+ case 'End': if (isOpen()) { e.preventDefault(); markFocus(visible().length - 1); } break;
581
+ case 'Enter': e.preventDefault(); isOpen() ? pick(visible()[focusIdx]) : open(); break;
582
+ case ' ': if (trigger.tagName !== 'INPUT') { e.preventDefault(); isOpen() ? pick(visible()[focusIdx]) : open(); } break;
583
+ case 'Escape': if (isOpen()) { e.preventDefault(); adapter.restore?.(); close({ focus: true }); } break;
584
+ case 'Tab': close(); break;
585
+ }
586
+ });
587
+
588
+ // the panel must not take focus off the trigger, or the blur above closes
589
+ // it before the option's own click can land
590
+ menu.addEventListener('mousedown', e => e.preventDefault());
591
+ options.forEach(opt => {
592
+ opt.addEventListener('click', () => pick(opt));
593
+ opt.addEventListener('pointermove', () => {
594
+ const pos = visible().indexOf(opt);
595
+ if (pos >= 0 && pos !== focusIdx) markFocus(pos);
596
+ });
597
+ });
598
+
599
+ root.querySelector('.select-clear')?.addEventListener('click', e => {
600
+ e.stopPropagation();
601
+ if (dead()) return;
602
+ adapter.clear();
603
+ root.dataset.value = adapter.value();
604
+ root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
605
+ });
606
+
607
+ selectControllers.push({ root, close });
608
+ });
609
+
610
+ if (once(document.body, 'uiSelectOutside')) {
611
+ document.addEventListener('pointerdown', e => {
612
+ selectControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
613
+ });
614
+ }
615
+ }
616
+
617
+ /* ============================= palette ============================ */
618
+
619
+ /* The query field keeps DOM focus for the whole interaction and the list is a
620
+ listbox, so the arrow-key row is named by aria-activedescendant. The controller
621
+ owns nothing a command does: it dispatches `run` on the backdrop with the row's
622
+ data-command. Ctrl/Cmd+K opens it whether or not a trigger button exists. */
623
+ export function initPalette() {
624
+ // the sheet's own catalogue anchor is named `palette`, so an id lookup finds
625
+ // the section that documents the component instead of the live palette
626
+ const backdrop = document.querySelector('.palette-backdrop');
627
+ if (!backdrop || !once(backdrop, 'uiPalette')) return;
628
+ const panel = backdrop.querySelector('.palette');
629
+ const input = backdrop.querySelector('.palette-input');
630
+ const list = backdrop.querySelector('.palette-list');
631
+ const empty = backdrop.querySelector('.palette-empty');
632
+ const announcer = backdrop.querySelector('[role="status"]');
633
+ const trigger = document.getElementById('palette-trigger');
634
+ if (!panel || !input || !list) return;
635
+ const rows = [...backdrop.querySelectorAll('.palette-row')];
636
+ const groups = [...backdrop.querySelectorAll('.palette-group')];
637
+
638
+ // a row matches on its own name plus its group's label, so "navigate" pulls
639
+ // the whole group. Keycaps stay out of it: nobody searches for a shortcut.
640
+ rows.forEach((row, i) => {
641
+ row.id ||= `palette-opt-${i}`;
642
+ const label = row.closest('.palette-group')?.querySelector('.palette-group-label');
643
+ row.dataset.hay = `${row.querySelector('.palette-name')?.textContent || ''} ${label?.textContent || ''}`
644
+ .toLowerCase().trim().replace(/\s+/g, ' ');
645
+ });
646
+
647
+ let shown = rows.slice();
648
+ let activeIdx = 0;
649
+ let lastFocus = null;
650
+ let closeTimer = 0;
651
+
652
+ // .closing counts as closed: a second Escape mid-exit must not restart the
653
+ // exit, and a reopen mid-exit must not return early
654
+ const isOpen = () => !backdrop.hidden && !backdrop.classList.contains('closing');
655
+ const exitMs = () => animMs(panel);
656
+
657
+ // keeps the active row inside the scroll box without scrollIntoView, which
658
+ // would scroll the page under the palette as well. A row that opens its
659
+ // group scrolls the group in, or Home lands on a cut-off label.
660
+ const reveal = row => {
661
+ const group = row.parentElement;
662
+ const opensGroup = row === group.querySelector('.palette-row:not([hidden])');
663
+ const top = (opensGroup ? group : row).offsetTop;
664
+ const bottom = row.offsetTop + row.offsetHeight;
665
+ if (top < list.scrollTop) list.scrollTop = top;
666
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
667
+ };
668
+
669
+ const markActive = i => {
670
+ if (!shown.length) { input.removeAttribute('aria-activedescendant'); return; }
671
+ activeIdx = (i + shown.length) % shown.length;
672
+ rows.forEach(r => { r.classList.remove('active'); r.setAttribute('aria-selected', 'false'); });
673
+ const row = shown[activeIdx];
674
+ row.classList.add('active');
675
+ row.setAttribute('aria-selected', 'true');
676
+ input.setAttribute('aria-activedescendant', row.id);
677
+ reveal(row);
678
+ };
679
+
680
+ const filter = () => {
681
+ const typed = input.value.trim();
682
+ // every word has to land, so the hint names the actual fix: another word
683
+ // for a one-word query, fewer for the rest
684
+ const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
685
+ shown = [];
686
+ rows.forEach(row => {
687
+ const hit = words.every(w => row.dataset.hay.includes(w));
688
+ row.hidden = !hit;
689
+ if (hit) shown.push(row);
690
+ });
691
+ groups.forEach(g => { g.hidden = !g.querySelector('.palette-row:not([hidden])'); });
692
+ const none = !shown.length;
693
+ list.hidden = none;
694
+ if (empty) empty.hidden = !none;
695
+ input.setAttribute('aria-expanded', String(!none));
696
+ if (none) {
697
+ const msg = words.length > 1
698
+ ? `No match for '${typed}'. Try fewer words.`
699
+ : `No match for '${typed}'. Try another word.`;
700
+ if (empty) empty.textContent = msg;
701
+ // the visible row is content; this region is what announces it, and it
702
+ // stays in the tree so the change lands on a live region
703
+ if (announcer) announcer.textContent = msg;
704
+ input.removeAttribute('aria-activedescendant');
705
+ } else {
706
+ if (announcer) announcer.textContent = '';
707
+ markActive(0);
708
+ }
709
+ };
710
+
711
+ const open = () => {
712
+ if (isOpen()) return;
713
+ clearTimeout(closeTimer);
714
+ backdrop.classList.remove('closing');
715
+ panel.classList.remove('closing');
716
+ lastFocus = document.activeElement;
717
+ backdrop.hidden = false;
718
+ input.value = '';
719
+ list.scrollTop = 0; // a hidden box keeps the last open's scroll position
720
+ filter();
721
+ input.focus();
722
+ };
723
+
724
+ const close = () => {
725
+ if (!isOpen()) return;
726
+ backdrop.classList.add('closing');
727
+ panel.classList.add('closing');
728
+ clearTimeout(closeTimer);
729
+ closeTimer = setTimeout(() => {
730
+ backdrop.hidden = true;
731
+ backdrop.classList.remove('closing');
732
+ panel.classList.remove('closing');
733
+ }, exitMs());
734
+ // focus goes back where it came from, or to the control that names the palette
735
+ const back = lastFocus && lastFocus.isConnected && lastFocus !== document.body ? lastFocus : trigger;
736
+ back?.focus();
737
+ };
738
+
739
+ // the palette names the command and closes; what a command does belongs to
740
+ // the page, keyed on data-command rather than on a label that gets reworded
741
+ const run = row => {
742
+ backdrop.dispatchEvent(new CustomEvent('run', {
743
+ bubbles: true,
744
+ detail: { command: row.dataset.command, name: row.querySelector('.palette-name')?.textContent },
745
+ }));
746
+ close();
747
+ };
748
+
749
+ panel.addEventListener('keydown', e => {
750
+ switch (e.key) {
751
+ case 'ArrowDown': e.preventDefault(); markActive(activeIdx + 1); break;
752
+ case 'ArrowUp': e.preventDefault(); markActive(activeIdx - 1); break;
753
+ case 'Home': e.preventDefault(); markActive(0); break;
754
+ case 'End': e.preventDefault(); markActive(shown.length - 1); break;
755
+ case 'Enter': if (shown.length) { e.preventDefault(); run(shown[activeIdx]); } break;
756
+ case 'Escape': e.preventDefault(); close(); break;
757
+ // one focusable element under an aria-modal, so Tab steps the row
758
+ // instead of walking focus into a document assistive tech cannot see
759
+ case 'Tab': e.preventDefault(); markActive(activeIdx + (e.shiftKey ? -1 : 1)); break;
760
+ }
761
+ });
762
+ input.addEventListener('input', filter);
763
+
764
+ // a press anywhere but the query field keeps focus on the query field
765
+ panel.addEventListener('mousedown', e => { if (e.target !== input) e.preventDefault(); });
766
+ backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) e.preventDefault(); });
767
+ backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
768
+
769
+ rows.forEach(row => {
770
+ row.addEventListener('click', () => run(row));
771
+ row.addEventListener('pointermove', () => {
772
+ const i = shown.indexOf(row);
773
+ if (i >= 0 && i !== activeIdx) markActive(i);
774
+ });
775
+ });
776
+
777
+ trigger?.addEventListener('click', open);
778
+ addEventListener('keydown', e => {
779
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open(); }
780
+ });
781
+ }
782
+
783
+ /* ============================= tooltip ============================ */
784
+
785
+ const tooltipControllers = [];
786
+
787
+ export function initTooltip() {
788
+ document.querySelectorAll('.tooltip').forEach(tip => {
789
+ const root = tip.parentElement;
790
+ const trigger = root && root.querySelector(`[aria-describedby="${tip.id}"]`);
791
+ if (!root || !trigger || !once(tip, 'uiTooltip')) return;
792
+
793
+ let hovering = false, focused = false, hideTimer = 0, hoverTimer = 0;
794
+ const isOpen = () => !tip.hidden;
795
+ const exitMs = () => animMs(tip);
796
+
797
+ const place = () => {
798
+ const r = root.getBoundingClientRect();
799
+ const w = tip.offsetWidth, h = tip.offsetHeight;
800
+ // no room above and room below: open toward the space that exists
801
+ tip.classList.toggle('below', r.top < h + 12 && innerHeight - r.bottom > h + 12);
802
+ let x = (r.width - w) / 2; // centered on the trigger
803
+ x = Math.max(8 - r.left, Math.min(x, innerWidth - 8 - w - r.left));
804
+ tip.style.left = Math.round(x) + 'px';
805
+ };
806
+
807
+ const open = () => {
808
+ clearTimeout(hoverTimer);
809
+ clearTimeout(hideTimer);
810
+ tip.classList.remove('closing'); // a reopen mid-exit restarts the entrance
811
+ if (isOpen()) { place(); return; }
812
+ tip.hidden = false;
813
+ place();
814
+ };
815
+ const close = () => {
816
+ if (!isOpen()) return;
817
+ tip.classList.add('closing');
818
+ clearTimeout(hideTimer);
819
+ hideTimer = setTimeout(() => {
820
+ tip.hidden = true;
821
+ tip.classList.remove('closing');
822
+ }, exitMs());
823
+ };
824
+ const maybeClose = () => { if (!hovering && !focused) close(); };
825
+
826
+ trigger.addEventListener('pointerenter', e => {
827
+ if (e.pointerType !== 'mouse') return;
828
+ hovering = true;
829
+ // hover intent: 300ms, so a scan across the toolbar does not flicker
830
+ clearTimeout(hoverTimer);
831
+ hoverTimer = setTimeout(open, 300);
832
+ });
833
+ trigger.addEventListener('pointerleave', e => {
834
+ if (e.pointerType !== 'mouse') return;
835
+ hovering = false;
836
+ clearTimeout(hoverTimer);
837
+ maybeClose();
838
+ });
839
+ // touch has no hover: the first contact shows it
840
+ trigger.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') open(); });
841
+ trigger.addEventListener('focus', () => { focused = true; open(); });
842
+ trigger.addEventListener('blur', () => { focused = false; maybeClose(); });
843
+ trigger.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
844
+
845
+ tooltipControllers.push({ root, tip, close, place, isOpen });
846
+ });
847
+
848
+ if (once(document.body, 'uiTooltipGlobal')) {
849
+ document.addEventListener('pointerdown', e => {
850
+ tooltipControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
851
+ });
852
+ // an open tooltip re-places on resize; it is anchored to its wrapper,
853
+ // so scrolling carries it with the page
854
+ addEventListener('resize', () => {
855
+ tooltipControllers.forEach(c => { if (c.isOpen()) c.place(); });
856
+ });
857
+ }
858
+ }
859
+
860
+ /* ============================== modal ============================= */
861
+
862
+ /* A dialog opens from `[data-modal-open="<backdrop id>"]` and closes on Escape, on
863
+ the backdrop's own click, and on any `[data-modal-close]` inside it.
864
+
865
+ THE FOCUS PROTOCOL IS THE PART EVERY CONSUMER GOT WRONG, so it lives here rather
866
+ than in prose. On open, every other child of <body> goes `inert`: that is the
867
+ focus trap, with no key handling and no tabbable-list arithmetic, and it also
868
+ takes the page behind out of the accessibility tree. Focus lands on the panel's
869
+ first close control, and returns to the opener at close.
870
+
871
+ A MENU THAT OPENS A MODAL STEALS FOCUS BACK, because the menu's own restore runs
872
+ after the dialog has taken it, and Escape then does nothing. Opening on the next
873
+ frame is what orders the two. */
874
+ // module scope, not per call: the listeners a first init() registered close over
875
+ // this map, so a second init() creating a fresh one would leave initModal.open
876
+ // writing to a map nothing reads
877
+ const modalState = new WeakMap();
878
+
879
+ export function initModal() {
880
+ const state = modalState;
881
+
882
+ const openers = document.querySelectorAll('[data-modal-open]');
883
+ openers.forEach(btn => {
884
+ if (!once(btn, 'uiModalOpen')) return;
885
+ btn.addEventListener('click', () => {
886
+ const backdrop = document.getElementById(btn.dataset.modalOpen);
887
+ if (backdrop) requestAnimationFrame(() => openModal(backdrop, btn));
888
+ });
889
+ });
890
+
891
+ // the palette is a modal backdrop wearing a second class, and its own controller
892
+ // owns its open, close, focus and exit. Two controllers on one element left it
893
+ // visible after a command ran, swallowing every click on the page behind it.
894
+ const dialogs = () => [...document.querySelectorAll('.modal-backdrop:not(.palette-backdrop)')];
895
+
896
+ dialogs().forEach(backdrop => {
897
+ if (!once(backdrop, 'uiModal')) return;
898
+ backdrop.addEventListener('click', e => {
899
+ if (e.target === backdrop || e.target.closest('[data-modal-close]')) closeModal(backdrop);
900
+ });
901
+ });
902
+
903
+ if (once(document.body, 'uiModalEsc')) {
904
+ addEventListener('keydown', e => {
905
+ if (e.key !== 'Escape') return;
906
+ const open = dialogs().filter(b => !b.hidden && !b.classList.contains('closing')).pop();
907
+ if (open) closeModal(open);
908
+ });
909
+ }
910
+
911
+ /* The ambient layer is not page content, so the trap never reaches it: the ring
912
+ and the grid paint, the toast stack still announces, and the drawn scrollbar
913
+ keeps taking a drag. `inert` on that bar would leave the dialog's own scroller
914
+ with no bar to grab, since the platform's is hidden by then. */
915
+ function siblings(backdrop) {
916
+ return [...document.body.children].filter(el =>
917
+ el !== backdrop && el.id !== 'cursor' && el.id !== 'bg-dots' && el.id !== 'toast-stack'
918
+ && !el.classList.contains('ui-scrollbar'));
919
+ }
920
+
921
+ function openModal(backdrop, opener) {
922
+ const s = state.get(backdrop) || {};
923
+ clearTimeout(s.timer);
924
+ backdrop.classList.remove('closing');
925
+ backdrop.querySelector('.modal')?.classList.remove('closing');
926
+ s.opener = opener || document.activeElement;
927
+ s.inert = siblings(backdrop).filter(el => !el.inert);
928
+ s.inert.forEach(el => { el.inert = true; });
929
+ state.set(backdrop, s);
930
+ backdrop.hidden = false;
931
+ const focusTarget = backdrop.querySelector('[data-modal-autofocus]')
932
+ || backdrop.querySelector('[data-modal-close]')
933
+ || backdrop.querySelector('.modal');
934
+ focusTarget?.focus?.();
935
+ }
936
+
937
+ function closeModal(backdrop) {
938
+ if (backdrop.hidden || backdrop.classList.contains('closing')) return;
939
+ const s = state.get(backdrop) || {};
940
+ const panel = backdrop.querySelector('.modal');
941
+ backdrop.classList.add('closing');
942
+ panel?.classList.add('closing');
943
+ clearTimeout(s.timer);
944
+ s.timer = setTimeout(() => {
945
+ backdrop.hidden = true;
946
+ backdrop.classList.remove('closing');
947
+ panel?.classList.remove('closing');
948
+ }, panel ? animMs(panel) : 180);
949
+ s.inert?.forEach(el => { el.inert = false; });
950
+ s.inert = null;
951
+ state.set(backdrop, s);
952
+ // an opener inside a popover is hidden the moment its menu folds, and
953
+ // focusing a hidden element does nothing, so the menu's own trigger is
954
+ // the fallback. The .closing branch covers the window where the menu
955
+ // has not hidden yet but will: focus lands on the row and then drops.
956
+ if (s.opener?.isConnected) s.opener.focus();
957
+ const menu = s.opener?.closest?.('.popover');
958
+ if (menu && (menu.hidden || menu.classList.contains('closing'))) {
959
+ document.querySelector(`[aria-controls="${menu.id}"]`)?.focus();
960
+ }
961
+ }
962
+
963
+ initModal.open = openModal;
964
+ initModal.close = closeModal;
965
+ }
966
+
967
+ /* ============================== toasts ============================ */
968
+
969
+ /* THE SPRITE IS THE ONE DRAWING OF EVERY GLYPH. These four were redrawn here
970
+ and each one had drifted from the symbol it stands for: the success tick ran
971
+ M5 8 l2 2 l4 -4 against the sprite's M5 8 L7 10 L11 6, the warning triangle
972
+ sat a half unit lower, and both dots were 0.5-unit strokes where the sprite
973
+ draws a .01 round cap. Nothing could see it, because a redrawn glyph passes
974
+ every guard the sprite has. */
975
+ const toastIcons = {
976
+ success: '<svg aria-hidden="true"><use href="#i-success"/></svg>',
977
+ warning: '<svg aria-hidden="true"><use href="#i-warning"/></svg>',
978
+ danger: '<svg aria-hidden="true"><use href="#i-error"/></svg>',
979
+ info: '<svg aria-hidden="true"><use href="#i-info"/></svg>',
980
+ };
981
+ const toastIconColors = {
982
+ success: 'var(--success)', warning: 'var(--warning)',
983
+ danger: 'var(--danger)', info: 'var(--accent)',
984
+ };
985
+
986
+ /* `title` and `msg` are caller strings: a service name, a log line, a filename.
987
+ They go in as TEXT. The markup around them is the only thing built from a
988
+ template, so a value carrying angle brackets lands as characters, not markup. */
989
+ export function showToast(type, title, msg) {
990
+ const stack = document.getElementById('toast-stack');
991
+ if (!stack) return null;
992
+ const t = document.createElement('div');
993
+ t.className = 'toast';
994
+ t.setAttribute('role', 'status');
995
+ t.innerHTML = `
996
+ <div class="toast-icon" style="color:${toastIconColors[type] || toastIconColors.info}">${toastIcons[type] || toastIcons.info}</div>
997
+ <div class="toast-body">
998
+ <div class="toast-title"></div>
999
+ <div class="toast-msg"></div>
1000
+ </div>
1001
+ <button class="close" aria-label="Dismiss">
1002
+ <svg aria-hidden="true"><use href="#i-x"/></svg>
1003
+ </button>`;
1004
+ t.querySelector('.toast-title').textContent = title;
1005
+ t.querySelector('.toast-msg').textContent = msg;
1006
+ t.querySelector('.close').addEventListener('click', () => removeToast(t));
1007
+ stack.appendChild(t);
1008
+ setTimeout(() => removeToast(t), 5000);
1009
+ return t;
1010
+ }
1011
+
1012
+ export function removeToast(t) {
1013
+ if (!t || !t.parentNode) return;
1014
+ t.classList.add('removing');
1015
+ setTimeout(() => t.remove(), 110); // --dur-out-near, the toast's own exit
1016
+ }
1017
+
1018
+ /* Dismiss controls that already sit in the markup: a toast written into the page,
1019
+ and the page-level banner. `.close` is the one remove. */
1020
+ export function initDismiss() {
1021
+ document.querySelectorAll('.close').forEach(btn => {
1022
+ if (!once(btn, 'uiDismiss')) return;
1023
+ btn.addEventListener('click', () => {
1024
+ const toast = btn.closest('.toast');
1025
+ if (toast) return removeToast(toast);
1026
+ const banner = btn.closest('.banner');
1027
+ if (!banner) return;
1028
+ banner.classList.add('closing');
1029
+ setTimeout(() => banner.remove(), animMs(banner));
1030
+ });
1031
+ });
1032
+ }
1033
+
1034
+ /* ============================== theme ============================= */
1035
+
1036
+ /* The saved theme is restored in <head> before the stylesheet loads, so a light
1037
+ visitor never sees a dark flash; that snippet cannot live
1038
+ here, because a module runs too late to beat the first paint. */
1039
+ export function setTheme(t) {
1040
+ document.documentElement.dataset.theme = t;
1041
+ try { localStorage.setItem('cloudy-theme', t); } catch { /* private mode */ }
1042
+ document.querySelectorAll('[data-theme-toggle]').forEach(b => {
1043
+ b.setAttribute('aria-pressed', String(t === 'light'));
1044
+ b.setAttribute('aria-label', t === 'light' ? 'Switch to dark theme' : 'Switch to light theme');
1045
+ });
1046
+ }
1047
+
1048
+ /* Exported, because the button is not the only thing that swaps a theme: a palette
1049
+ command and a keyboard shortcut both want to call this, and inlining it in the
1050
+ click handler is what made a consumer write its own copy. */
1051
+ export function toggleTheme() {
1052
+ const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
1053
+ // the crossfade snapshots the old frame and fades the two together; a
1054
+ // browser without view transitions gets the same swap on the spot
1055
+ if (!document.startViewTransition) return setTheme(next);
1056
+ document.startViewTransition(() => setTheme(next));
1057
+ }
1058
+
1059
+ export function initTheme() {
1060
+ setTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
1061
+ document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
1062
+ if (!once(btn, 'uiTheme')) return;
1063
+ btn.addEventListener('click', toggleTheme);
1064
+ });
1065
+ }
1066
+
1067
+ /* ====================== reveals and sticky headers ====================== */
1068
+
1069
+ /* --vp-w is the viewport width minus a classic scrollbar, which 100vw wrongly
1070
+ includes. Every full-bleed rule reads it (.band, .section-sticky.is-stuck), so
1071
+ it is written unconditionally: the old split, where only the reveal controller
1072
+ wrote it, shipped a horizontal scrollbar on any page with a sticky header and
1073
+ no reveals. In a sidebar shell, call this with the scrolling column instead. */
1074
+ export function initViewportWidth(el = document.documentElement) {
1075
+ const set = () => el.style.setProperty('--vp-w', el.clientWidth + 'px');
1076
+ set();
1077
+ // the guard is per element, not global: a sidebar shell calls this a second time
1078
+ // for its scrolling column, and a global latch would leave that column's --vp-w
1079
+ // correct at boot and frozen on every resize after it
1080
+ if (once(el === document.documentElement ? document.body : el, 'uiVpw')) {
1081
+ addEventListener('resize', set);
1082
+ }
1083
+ }
1084
+
1085
+ /* A 1px sentinel above each header toggles .is-stuck. Shipped in two places
1086
+ before this file existed, so a page pasting both blocks inserted two sentinels
1087
+ per header and toggled the class twice per scroll.
1088
+
1089
+ A SENTINEL IS OUT OF VIEW IN BOTH DIRECTIONS, so `!isIntersecting` alone reads
1090
+ "the reader has not reached this section yet" as "this header is stuck". The
1091
+ stuck costume is a full-bleed page-toned band, so the reference sheet's one
1092
+ header painted that band at its natural position for the whole scroll and
1093
+ dropped it on arrival, the state backwards from top to bottom: measured, stuck
1094
+ with its top 14080px below the fold and unstuck with its top at 80. Which side
1095
+ the sentinel left on is the whole state, so the side is what the test reads. */
1096
+ export function initSticky() {
1097
+ document.querySelectorAll('.section-sticky').forEach(h => {
1098
+ if (!once(h, 'uiSticky')) return;
1099
+ const sentinel = document.createElement('div');
1100
+ h.parentNode.insertBefore(sentinel, h);
1101
+ /* THE PIN OFFSET IS A BREAKPOINT, not a constant: the header pins under the
1102
+ navbar at 56px above 768 and under the mobile topbar at 52 below it, and
1103
+ both the rootMargin and the side test are built from it. Read once, a
1104
+ viewport crossing 768 left the class flipping 4px early, exactly the
1105
+ difference between the two bars. `initViewportWidth` in this same section
1106
+ already re-reads on resize for the same reason. */
1107
+ let io = null;
1108
+ const arm = () => {
1109
+ const top = parseInt(getComputedStyle(h).top) || 0;
1110
+ if (io) io.disconnect();
1111
+ io = new IntersectionObserver(
1112
+ ([e]) => h.classList.toggle('is-stuck',
1113
+ !e.isIntersecting && e.boundingClientRect.top < top + 1),
1114
+ { rootMargin: `-${top + 1}px 0px 0px 0px`, threshold: 1 }
1115
+ );
1116
+ io.observe(sentinel);
1117
+ };
1118
+ arm();
1119
+ addEventListener('resize', arm);
1120
+ });
1121
+ }
1122
+
1123
+ /* JS adds the hiding class at runtime, so no-JS keeps the content visible.
1124
+ Reduced motion skips the hide entirely rather than zeroing a duration. */
1125
+ export function initReveal() {
1126
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
1127
+ if (reduced || !('IntersectionObserver' in window)) return;
1128
+
1129
+ const targets = [...document.querySelectorAll('[data-reveal]')].filter(el => once(el, 'uiReveal'));
1130
+ if (!targets.length) return;
1131
+ targets.forEach(el => el.classList.add('reveal')); // hiding class added by JS, never authored
1132
+
1133
+ /* The stagger belongs to what arrives TOGETHER, so it is applied at reveal time
1134
+ rather than at init. A document-order index makes a section that crosses the
1135
+ trigger on its own wait for a position the reader cannot see: measured on the
1136
+ sheet, section 5 first moved at 250ms and settled at 399, its neighbour 6
1137
+ moved at 60 and settled at 211, on no grouping reason. 40ms, capped at six. */
1138
+ const land = els => els.forEach((el, i) => {
1139
+ el.style.transitionDelay = Math.min(i, 5) * 40 + 'ms';
1140
+ el.classList.add('in');
1141
+ io.unobserve(el);
1142
+ });
1143
+
1144
+ const io = new IntersectionObserver(
1145
+ entries => land(entries.filter(e => e.isIntersecting).map(e => e.target)),
1146
+ { threshold: 0, rootMargin: '0px 0px -10% 0px' });
1147
+ targets.forEach(el => io.observe(el));
1148
+
1149
+ /* The net covers an observer that never fired, so it reveals only what is ON
1150
+ SCREEN when it lands. Revealing every target instead settles the whole page
1151
+ before the reader reaches any of it: measured on the rendered sheet, 44 of
1152
+ 46 sections were off screen at 1500ms, so no section could ever animate. */
1153
+ setTimeout(() => land(targets.filter(el => {
1154
+ if (el.classList.contains('in')) return false;
1155
+ const r = el.getBoundingClientRect();
1156
+ return r.top < innerHeight && r.bottom > 0;
1157
+ })), 1500);
1158
+ }
1159
+
1160
+ /* ============================= chart entrance ============================= */
1161
+
1162
+ /* JS adds the hiding class at runtime, so a page with no controller paints its
1163
+ marks instead of nothing. `.is-drawn` lands once, when the chart intersects
1164
+ the viewport, and the observer unobserves it: the entrance never replays on a
1165
+ second scroll past. */
1166
+ /* A card whose whole surface is the control cannot be a `<button>`: its content
1167
+ is block-level, and a button may not hold a heading or a paragraph. So it is a
1168
+ div with `role="button"` and a tabindex, and a div gets no activation for
1169
+ free: Enter and Space are wired here, and Space's own default is refused
1170
+ because it scrolls the page. Reaching the card is the consumer's half of the
1171
+ contract (the two attributes); activating it is this system's. */
1172
+ function initCardButtons() {
1173
+ document.querySelectorAll('.card-interactive[role="button"]').forEach(card => {
1174
+ if (!once(card, 'uiCardButton')) return;
1175
+ if (!card.hasAttribute('tabindex')) card.tabIndex = 0;
1176
+ card.addEventListener('keydown', e => {
1177
+ if (e.key !== 'Enter' && e.key !== ' ') return;
1178
+ e.preventDefault();
1179
+ card.click();
1180
+ });
1181
+ });
1182
+ }
1183
+
1184
+ /* A legend built from buttons is a control, and the design docs had described this
1185
+ one for a round before anything wrote it: every key on every page was inert
1186
+ text beside a swatch. The toggle names its slot with the same
1187
+ `chart-series-N` class the mark carries, so the pair cannot drift, and it
1188
+ finds its chart by walking up to the first ancestor holding one, which lets a
1189
+ legend sit either side of the figure with nothing to wire. */
1190
+ function initLegend() {
1191
+ document.querySelectorAll('.legend-toggle').forEach(btn => {
1192
+ const slot = [...btn.classList].find(c => /^chart-series-\d+$/.test(c));
1193
+ if (!slot || !once(btn, 'uiLegend')) return;
1194
+ let chart = null;
1195
+ for (let p = btn.parentElement; p && !chart; p = p.parentElement) chart = p.querySelector('.chart');
1196
+ if (!chart) return;
1197
+ btn.addEventListener('click', () => {
1198
+ const on = btn.getAttribute('aria-pressed') !== 'false';
1199
+ btn.setAttribute('aria-pressed', String(!on));
1200
+ chart.querySelectorAll(`.chart-series.${slot}`).forEach(s => s.classList.toggle('is-off', on));
1201
+ });
1202
+ });
1203
+ }
1204
+
1205
+ export function initChart() {
1206
+ initLegend();
1207
+ const charts = [...document.querySelectorAll('.chart')].filter(el => once(el, 'uiChart'));
1208
+ if (!charts.length) return;
1209
+
1210
+ // the hiding class lands in this same synchronous task, before the first paint
1211
+ charts.forEach(el => el.classList.add('will-draw'));
1212
+
1213
+ if (!('IntersectionObserver' in window)) {
1214
+ charts.forEach(el => el.classList.add('is-drawn'));
1215
+ return;
1216
+ }
1217
+ const io = new IntersectionObserver(entries => {
1218
+ entries.forEach(e => {
1219
+ if (e.isIntersecting) {
1220
+ e.target.classList.add('is-drawn');
1221
+ io.unobserve(e.target);
1222
+ }
1223
+ });
1224
+ }, { threshold: 0, rootMargin: '0px 0px -10% 0px' });
1225
+ charts.forEach(el => io.observe(el));
1226
+ }
1227
+
1228
+ /* ========================== popovers and menus ========================== */
1229
+
1230
+ /* Any `.popover` opened by a button that names it with `aria-controls` AND
1231
+ `aria-haspopup`. Covers the table's filter menu and the context menu, and it is
1232
+ what `.popover` needed to stop being an entrance with no exit: the exit runs the
1233
+ `.closing` costume for the popover's own animation duration.
1234
+
1235
+ THE GLOBAL HANDLERS TOUCH ONLY THE PAIRS THIS CONTROLLER WIRED. `.popover` is
1236
+ also the shell the palette and the select panel are built from, so a handler
1237
+ written against every `.popover` on the page reaches into components that own
1238
+ their own open state: it hid the palette's panel behind its own backdrop, and
1239
+ the next open showed an empty dialog that swallowed every click on the page. */
1240
+ const popoverPairs = [];
1241
+
1242
+ export function initPopovers() {
1243
+ const isControl = t => t.closest?.('button, a, input, select, textarea, label');
1244
+ const close = (menu, btn) => {
1245
+ if (menu.hidden || menu.classList.contains('closing')) return;
1246
+ menu.classList.add('closing');
1247
+ btn?.setAttribute('aria-expanded', 'false');
1248
+ setTimeout(() => { menu.hidden = true; menu.classList.remove('closing'); }, animMs(menu));
1249
+ };
1250
+
1251
+ document.querySelectorAll('[aria-controls][aria-haspopup]').forEach(btn => {
1252
+ const menu = document.getElementById(btn.getAttribute('aria-controls'));
1253
+ if (!menu || !menu.classList.contains('popover') || !once(btn, 'uiPopover')) return;
1254
+ popoverPairs.push({ btn, menu, close });
1255
+ // a checkbox row has to survive several picks, so an input inside the
1256
+ // item keeps the menu open; a button row is an action, and an action
1257
+ // row that opens a modal leaves a menu open behind the dialog, whose
1258
+ // Escape handler then eats the dialog's
1259
+ menu.addEventListener('click', e => {
1260
+ const item = e.target.closest?.('.menu-item');
1261
+ if (!item || isControl(e.target)) return;
1262
+ close(menu, btn);
1263
+ btn.focus();
1264
+ });
1265
+ menu.addEventListener('click', e => {
1266
+ if (!e.target.closest?.('button.menu-item')) return;
1267
+ close(menu, btn);
1268
+ btn.focus();
1269
+ });
1270
+ btn.addEventListener('click', () => {
1271
+ if (!menu.hidden) return close(menu, btn);
1272
+ menu.classList.remove('closing');
1273
+ menu.hidden = false;
1274
+ btn.setAttribute('aria-expanded', 'true');
1275
+ });
1276
+ });
1277
+ if (!once(document.body, 'uiPopoverGlobal')) return;
1278
+
1279
+ const openPairs = () => popoverPairs.filter(p => !p.menu.hidden);
1280
+ document.addEventListener('pointerdown', e => {
1281
+ openPairs().forEach(({ btn, menu }) => {
1282
+ if (menu.contains(e.target) || btn.contains(e.target)) return;
1283
+ close(menu, btn);
1284
+ });
1285
+ });
1286
+ document.addEventListener('keydown', e => {
1287
+ if (e.key !== 'Escape') return;
1288
+ const open = openPairs();
1289
+ if (!open.length) return;
1290
+ e.stopPropagation(); // a menu's Escape belongs to the menu, not to a dialog behind it
1291
+ open.forEach(({ btn, menu }) => { close(menu, btn); btn.focus(); });
1292
+ });
1293
+ }
1294
+
1295
+ /* ============================== tables ============================= */
1296
+
1297
+ /* The DOM half of sort, selection and paging. The data half belongs to the page,
1298
+ which renders rows and listens for these events:
1299
+ `table:sort` {key, dir} dir is 'asc' | 'desc' | 'none'
1300
+ `table:selection` {selected} the data-name values currently checked
1301
+ `table:page` {page}
1302
+ Every one of them bubbles from the `.table-wrap`. Nothing here needs to know
1303
+ what a row holds, which is why it can wire every table on every page. */
1304
+ export function initTables() {
1305
+ const ariaSort = { asc: 'ascending', desc: 'descending' };
1306
+
1307
+ document.querySelectorAll('.table-wrap').forEach(wrap => {
1308
+ // outside the once() guard, so a table that re-renders its head is wrapped again.
1309
+ // A sortable cell's text lives in the button, not the cell, so both are offered.
1310
+ wrap.querySelectorAll('thead th, thead .th-sort').forEach(el => ownLabel(el, 'th-label'));
1311
+ if (!once(wrap, 'uiTable')) return;
1312
+ const state = { key: null, dir: 'none' };
1313
+
1314
+ wrap.querySelectorAll('.th-sort').forEach(btn => {
1315
+ btn.addEventListener('click', () => {
1316
+ const key = btn.dataset.sort;
1317
+ const th = btn.closest('th');
1318
+ if (state.key === key) {
1319
+ state.dir = state.dir === 'none' ? 'asc' : state.dir === 'asc' ? 'desc' : 'none';
1320
+ } else { state.key = key; state.dir = 'asc'; }
1321
+ wrap.querySelectorAll('th[aria-sort]').forEach(h => {
1322
+ h.setAttribute('aria-sort', h === th && state.dir !== 'none' ? ariaSort[state.dir] : 'none');
1323
+ });
1324
+ wrap.dispatchEvent(new CustomEvent('table:sort', { bubbles: true, detail: { ...state } }));
1325
+ });
1326
+ });
1327
+
1328
+ const selectAll = wrap.querySelector('input[type="checkbox"][data-select-all], thead input[type="checkbox"]');
1329
+ const boxes = () => [...wrap.querySelectorAll('tbody input[type="checkbox"][data-name]')];
1330
+ const selected = () => boxes().filter(b => b.checked).map(b => b.dataset.name);
1331
+ const syncAll = () => {
1332
+ if (!selectAll) return;
1333
+ const all = boxes();
1334
+ const n = all.filter(b => b.checked).length;
1335
+ selectAll.checked = all.length > 0 && n === all.length;
1336
+ selectAll.indeterminate = n > 0 && n < all.length;
1337
+ };
1338
+ const announce = () => wrap.dispatchEvent(new CustomEvent('table:selection',
1339
+ { bubbles: true, detail: { selected: selected() } }));
1340
+
1341
+ selectAll?.addEventListener('change', () => {
1342
+ boxes().forEach(b => {
1343
+ b.checked = selectAll.checked;
1344
+ // the class toggles in place, so the selection tint transitions
1345
+ b.closest('tr')?.classList.toggle('row-selected', b.checked);
1346
+ });
1347
+ syncAll();
1348
+ announce();
1349
+ });
1350
+ wrap.addEventListener('change', e => {
1351
+ const b = e.target.closest?.('tbody input[type="checkbox"][data-name]');
1352
+ if (!b) return;
1353
+ b.closest('tr')?.classList.toggle('row-selected', b.checked);
1354
+ syncAll();
1355
+ announce();
1356
+ });
1357
+ // the row is the target, the same ruling the setting row runs on: a click
1358
+ // anywhere on a selectable row that missed a control drives its own box
1359
+ wrap.addEventListener('click', e => {
1360
+ if (e.target.closest?.('button, a, input, select, textarea, label')) return;
1361
+ e.target.closest('tr')?.querySelector('input[type="checkbox"][data-name]')?.click();
1362
+ });
1363
+
1364
+ // a re-rendered body arrives with its own checked state: re-derive rather
1365
+ // than carry the last render's count
1366
+ new MutationObserver(syncAll).observe(wrap, { childList: true, subtree: true });
1367
+ syncAll();
1368
+ });
1369
+
1370
+ /* A pager usually sits OUTSIDE the .table-wrap it drives, so an event dispatched
1371
+ from it bubbles past the wrap and never reaches a listener there. Name the
1372
+ table with `aria-controls` and the event lands on that table's wrap, next to
1373
+ `table:sort` and `table:selection`. With no `aria-controls` it fires on the
1374
+ pager itself, which is then where the page has to listen. */
1375
+ document.querySelectorAll('.pagination').forEach(pager => {
1376
+ if (!once(pager, 'uiPager')) return;
1377
+ pager.addEventListener('click', e => {
1378
+ const b = e.target.closest('button[data-page]');
1379
+ if (!b || b.disabled) return;
1380
+ const named = pager.getAttribute('aria-controls');
1381
+ const target = (named && document.getElementById(named)?.closest('.table-wrap'))
1382
+ || b.closest('.table-wrap') || pager;
1383
+ target.dispatchEvent(new CustomEvent('table:page',
1384
+ { bubbles: true, detail: { page: Number(b.dataset.page) } }));
1385
+ });
1386
+ });
1387
+ }
1388
+
1389
+ /* ============================= code copy =========================== */
1390
+
1391
+ /* Copies the block's text and swaps to a check for a beat. A failed write leaves
1392
+ the button unchanged; there is no state to celebrate. */
1393
+ export function initCodeCopy() {
1394
+ document.querySelectorAll('.code-copy').forEach(btn => {
1395
+ const code = btn.closest('.code-body')?.querySelector('code');
1396
+ if (!code || !once(btn, 'uiCopy')) return;
1397
+ const iconCopy = btn.querySelector('.icon-copy');
1398
+ const iconCheck = btn.querySelector('.icon-check');
1399
+ let timer = 0;
1400
+ btn.addEventListener('click', async () => {
1401
+ try {
1402
+ await navigator.clipboard.writeText(code.textContent);
1403
+ } catch (err) {
1404
+ console.warn('copy failed', err);
1405
+ return;
1406
+ }
1407
+ if (iconCopy) iconCopy.hidden = true;
1408
+ if (iconCheck) iconCheck.hidden = false;
1409
+ btn.setAttribute('aria-label', 'Copied');
1410
+ clearTimeout(timer);
1411
+ timer = setTimeout(() => {
1412
+ if (iconCopy) iconCopy.hidden = false;
1413
+ if (iconCheck) iconCheck.hidden = true;
1414
+ btn.setAttribute('aria-label', 'Copy code');
1415
+ }, 1200);
1416
+ });
1417
+ });
1418
+ }
1419
+
1420
+ /* ======================= typed placeholder ======================= */
1421
+
1422
+ /* The third sanctioned ambient effect. An overlay sitting on the field types one
1423
+ real example, holds it, deletes it, and moves to the next. Ported from the
1424
+ contact form it was built for; the timings are that build's, unchanged.
1425
+
1426
+ A field declares its own examples and nothing else:
1427
+ <input class="input" data-typed='["you@example.com", "discord: cool_person"]'>
1428
+
1429
+ Three things the obvious version gets wrong:
1430
+ - It renders REAL example values, so it is content rather than decoration, and
1431
+ it takes a text colour that clears 4.5:1 rather than a placeholder grey.
1432
+ - A value in the field hides the overlay, so the loop parks at the frame before
1433
+ the next example instead of running invisibly, and resumes on the next clear.
1434
+ Resuming moves forward, so the interrupted example does not replay.
1435
+ - Autofill never fires `input`, so a value already present at boot is checked. */
1436
+ const TYPE_CHAR = 80, TYPE_ERASE = 50, TYPE_HOLD = 5000, TYPE_GAP = 400, TYPE_START = 600;
1437
+
1438
+ export function initTypedPlaceholder() {
1439
+ // ambient motion is off under reduced motion, and this one has no reduced form:
1440
+ // a typed line with the typing removed is just a placeholder the field already has
1441
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1442
+
1443
+ document.querySelectorAll('input[data-typed], textarea[data-typed]').forEach(input => {
1444
+ if (!once(input, 'uiTyped')) return;
1445
+ let examples;
1446
+ try {
1447
+ examples = JSON.parse(input.dataset.typed);
1448
+ } catch {
1449
+ console.warn('data-typed is not JSON on', input);
1450
+ return;
1451
+ }
1452
+ if (!Array.isArray(examples) || !examples.length) return;
1453
+
1454
+ const wrap = document.createElement('div');
1455
+ wrap.className = 'typed-wrap';
1456
+ input.parentNode.insertBefore(wrap, input);
1457
+ wrap.appendChild(input);
1458
+ // a marker this module owns, aria-hidden like the inks: the field's own
1459
+ // label names it, so the typed examples need no second voice
1460
+ const overlay = ownChild(wrap, 'typed-overlay');
1461
+
1462
+ let idx = 0, timer = 0, paused = false;
1463
+
1464
+ // one span per character, so each one fades on its own clock
1465
+ const build = text => {
1466
+ overlay.textContent = '';
1467
+ const frag = document.createDocumentFragment();
1468
+ for (const ch of text) {
1469
+ const span = document.createElement('span');
1470
+ span.className = 'typed-char';
1471
+ span.textContent = ch;
1472
+ frag.appendChild(span);
1473
+ }
1474
+ overlay.appendChild(frag);
1475
+ return [...overlay.children];
1476
+ };
1477
+
1478
+ const type = (spans, i, done) => {
1479
+ if (i >= spans.length) { timer = setTimeout(done, TYPE_HOLD); return; }
1480
+ spans[i].style.opacity = '1';
1481
+ timer = setTimeout(() => type(spans, i + 1, done), TYPE_CHAR);
1482
+ };
1483
+ const erase = (spans, i, done) => {
1484
+ if (i < 0) { done(); return; }
1485
+ spans[i].style.opacity = '0';
1486
+ timer = setTimeout(() => erase(spans, i - 1, done), TYPE_ERASE);
1487
+ };
1488
+ const cycle = () => {
1489
+ const spans = build(examples[idx]);
1490
+ new Promise(done => type(spans, 0, done))
1491
+ .then(() => new Promise(done => erase(spans, spans.length - 1, done)))
1492
+ .then(() => {
1493
+ idx = (idx + 1) % examples.length;
1494
+ timer = setTimeout(cycle, TYPE_GAP);
1495
+ });
1496
+ };
1497
+
1498
+ const pause = () => {
1499
+ paused = true;
1500
+ clearTimeout(timer);
1501
+ overlay.textContent = '';
1502
+ idx = (idx + 1) % examples.length; // resume moves forward, never replays
1503
+ };
1504
+ const resume = () => { paused = false; timer = setTimeout(cycle, TYPE_GAP); };
1505
+
1506
+ input.addEventListener('input', () => {
1507
+ const filled = !!input.value;
1508
+ overlay.hidden = filled;
1509
+ if (filled && !paused) pause();
1510
+ else if (!filled && paused) resume();
1511
+ });
1512
+
1513
+ if (input.value) { overlay.hidden = true; pause(); }
1514
+ else timer = setTimeout(cycle, TYPE_START);
1515
+ });
1516
+ }
1517
+
1518
+ /* ============================ scroll fade =========================== */
1519
+
1520
+ /* The cue that a scroller has more. The scrollers carry it themselves, so no
1521
+ consumer opts in: this one selector names every component the stylesheet makes
1522
+ scroll, and the controller writes the cue class onto each. The axis is derived
1523
+ from what the element actually scrolls, never from a class the author wrote.
1524
+ `no-scroll-fade` on any scroller is the one opt-out. Off at rest, so a no-JS
1525
+ page paints no cue. The two has-more classes are the CSS's own gate. */
1526
+ const SCROLL_FADE_SELECTOR = 'pre, .table-wrap, .chart-scroll, .modal-body, .log-view, .pagination-pages, .sidebar-nav, .navbar-nav, .select-menu, .palette-list';
1527
+
1528
+ export function initScrollFade() {
1529
+ document.querySelectorAll(SCROLL_FADE_SELECTOR).forEach(el => {
1530
+ if (el.classList.contains('no-scroll-fade') || !once(el, 'uiFade')) return;
1531
+ el.classList.add('scroll-fade');
1532
+ const sync = () => {
1533
+ const y = el.scrollHeight > el.clientHeight + 1;
1534
+ el.classList.toggle('scroll-fade-y', y);
1535
+ const pos = y ? el.scrollTop : el.scrollLeft;
1536
+ const view = y ? el.clientHeight : el.clientWidth;
1537
+ const full = y ? el.scrollHeight : el.scrollWidth;
1538
+ el.classList.toggle('has-more-start', pos > 1);
1539
+ el.classList.toggle('has-more-end', pos + view < full - 1);
1540
+ };
1541
+ el.addEventListener('scroll', sync, { passive: true });
1542
+ addEventListener('resize', sync);
1543
+ /* The box changes size with no scroll and no window resize: the rail collapse
1544
+ takes this nav's clientHeight 756 to 722, which left the cue saying nothing
1545
+ was below with 34px still there, until the next scroll. */
1546
+ new ResizeObserver(sync).observe(el);
1547
+ sync();
1548
+ });
1549
+ }
1550
+
1551
+ /* ============================ scrollbar =========================== */
1552
+
1553
+ /* The page draws its own, because `cursor: none` cannot reach a native
1554
+ scrollbar's shadow tree and the platform pointer paints over the ring for as
1555
+ long as one is dragged. `components.css` hides the native bars; this puts a
1556
+ real element back, so the ring paints over it like any other surface.
1557
+
1558
+ ONE bar per axis for the whole page, moved onto whichever scroller is active,
1559
+ which is what an overlay scrollbar already is. Per-scroller bars would need a
1560
+ positioned wrapper around every scrolling component in the system.
1561
+
1562
+ It appears on any pointer move inside a scroller and on a scroll, and retires
1563
+ `SCROLLBAR_IDLE` after both stop, her call 2026-09-02. Two things hold it past
1564
+ that: a drag, and the pointer resting on the thumb itself, or the bar would
1565
+ retire out from under a pointer on its way to grab it. */
1566
+ const SCROLLBAR_IDLE = 900;
1567
+ const SCROLLBAR_MIN = 28; // a thumb shorter than this stops reading as a handle
1568
+ const SCROLLBAR_GAP = 2; // the scroller's own edge to the thumb's outer edge
1569
+ const SCROLLBAR_HOLD = 10; // slop around a 6px thumb, so the hold is reachable
1570
+ const SCROLLBAR_Z = 999; // the bar's own slot in the stacking ladder
1571
+ const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
1572
+ // sub-pixel rounding in a stuck header's rect, never
1573
+ // a real strip of visible page
1574
+
1575
+ export function initScrollbars() {
1576
+ if (!once(document.documentElement, 'uiScrollbars')) return;
1577
+ const root = document.scrollingElement || document.documentElement;
1578
+ const bars = { y: document.createElement('div'), x: document.createElement('div') };
1579
+ bars.y.className = 'ui-scrollbar ui-scrollbar-y';
1580
+ bars.x.className = 'ui-scrollbar ui-scrollbar-x';
1581
+ for (const b of Object.values(bars)) b.setAttribute('aria-hidden', 'true');
1582
+ document.body.append(bars.y, bars.x);
1583
+ // the stylesheet hides the platform's bars only once these two exist
1584
+ document.documentElement.classList.add('has-drawn-scrollbars');
1585
+
1586
+ const view = (el, axis) => axis === 'y'
1587
+ ? (el === root ? innerHeight : el.clientHeight)
1588
+ : (el === root ? innerWidth : el.clientWidth);
1589
+ const full = (el, axis) => axis === 'y' ? el.scrollHeight : el.scrollWidth;
1590
+ const at = (el, axis) => axis === 'y' ? el.scrollTop : el.scrollLeft;
1591
+
1592
+ const scrolls = (el, axis) => {
1593
+ if (full(el, axis) <= view(el, axis) + 1) return false;
1594
+ if (el === root) return true;
1595
+ const ov = getComputedStyle(el)[axis === 'y' ? 'overflowY' : 'overflowX'];
1596
+ return ov === 'auto' || ov === 'scroll';
1597
+ };
1598
+ // the INNERMOST scroller under a node, so a table inside a modal wins
1599
+ const scrollerFrom = node => {
1600
+ for (let el = node; el instanceof Element; el = el.parentElement) {
1601
+ if (scrolls(el, 'y') || scrolls(el, 'x')) return el;
1602
+ }
1603
+ return root;
1604
+ };
1605
+ const boxOf = el => el === root
1606
+ ? { top: 0, left: 0, right: innerWidth, bottom: innerHeight }
1607
+ : el.getBoundingClientRect();
1608
+
1609
+ /* A rounded panel takes its own corner out from under the thumb, so a track
1610
+ running the full length leaves the thumb's last pixels outside the paint.
1611
+ The corner is often not the scroller's own: a modal body scrolls inside the
1612
+ modal's radius and declares none itself, so the radius comes from every box
1613
+ that shares the edge the bar runs along. The thumb's outer edge sits
1614
+ SCROLLBAR_GAP in from that edge, and a corner of radius r is inset by more
1615
+ than the gap for the first r - sqrt(2*r*gap - gap^2) px along it: 6.79px on
1616
+ the 14px corner of --r-2 at a 2px gap. The track gives that up at each end.
1617
+
1618
+ Cached per element, because it is layout that decides which ancestors share
1619
+ an edge and the walk would otherwise run on every frame of a scroll. */
1620
+ const inset = r => (r <= SCROLLBAR_GAP ? 0
1621
+ : r - Math.sqrt(2 * r * SCROLLBAR_GAP - SCROLLBAR_GAP * SCROLLBAR_GAP));
1622
+ let radii = new WeakMap();
1623
+ const corner = (el, axis) => {
1624
+ let cached = radii.get(el);
1625
+ if (!cached) radii.set(el, cached = {});
1626
+ if (cached[axis]) return cached[axis];
1627
+ const pads = [0, 0];
1628
+ if (el !== root) {
1629
+ const own = el.getBoundingClientRect();
1630
+ const px = v => (v.includes('px') ? parseFloat(v) || 0 : 0);
1631
+ const same = (a, b) => Math.abs(a - b) <= 1;
1632
+ for (let a = el; a instanceof Element; a = a.parentElement) {
1633
+ const r = a.getBoundingClientRect();
1634
+ const cs = getComputedStyle(a);
1635
+ if (axis === 'y' && same(r.right, own.right)) {
1636
+ if (same(r.top, own.top)) pads[0] = Math.max(pads[0], inset(px(cs.borderTopRightRadius)));
1637
+ if (same(r.bottom, own.bottom)) pads[1] = Math.max(pads[1], inset(px(cs.borderBottomRightRadius)));
1638
+ } else if (axis === 'x' && same(r.bottom, own.bottom)) {
1639
+ if (same(r.left, own.left)) pads[0] = Math.max(pads[0], inset(px(cs.borderBottomLeftRadius)));
1640
+ if (same(r.right, own.right)) pads[1] = Math.max(pads[1], inset(px(cs.borderBottomRightRadius)));
1641
+ }
1642
+ }
1643
+ }
1644
+ cached[axis] = pads;
1645
+ return pads;
1646
+ };
1647
+
1648
+ /* Chrome pinned over the scrollport hides the track under it, and a thumb
1649
+ drawn there marks a position nobody can scroll to. Her call 2026-09-02, and
1650
+ the covers are DERIVED rather than named: every visible fixed or sticky box
1651
+ is a candidate, and one counts against this bar only where it overlaps the
1652
+ bar's own lane and sits below the bar in the stacking ladder.
1653
+
1654
+ A cover counts only CONTIGUOUS with the run it joins: it may start no
1655
+ further than SCROLLBAR_CHAIN past the end already given up, which is what
1656
+ lets a header stuck flush under a navbar extend the run the navbar
1657
+ started. A navbar floating clear of the viewport edge as a card counts
1658
+ for nothing, because the strip of page above it is fully visible and the
1659
+ thumb's origin must not move with the viewport width; an unstuck sticky
1660
+ header sitting mid-page stays out of the sum the same way. The ambient
1661
+ layer drops out by taking no pointer, the scroller's own pinned ancestors
1662
+ by containment: the sidebar is fixed and spans its nav's whole
1663
+ scrollport. */
1664
+ /* The stylesheets name which selectors CAN pin, so the candidates come from one
1665
+ `querySelectorAll` rather than a computed read of every element: that read
1666
+ forces rendering inside any `content-visibility` subtree it crosses, and this
1667
+ system's collapsed rows all carry one. Selectors are proved usable once,
1668
+ because one bad one poisons the whole list. The list itself is static, since
1669
+ stylesheets do not change; the VERDICTS are not, so they are re-read every
1670
+ time. An earlier version cached them per interaction burst on the claim that
1671
+ an overlay can only mount between bursts. That claim was false: the move that
1672
+ carries a pointer to a control arms the timer, so the click that opens the
1673
+ overlay always lands inside a live burst, and the stale verdict shipped for up
1674
+ to a full idle period. The whole walk measures 0.37ms on the reference sheet
1675
+ (16 selectors, 18 matches, 9 kept), which buys nothing worth being wrong for.
1676
+
1677
+ One cover this can never name: a pseudo-element. `collectPropSelectors` drops
1678
+ any selector holding `::`, so the navbar's fixed `body::before` dock strip is
1679
+ outside the derivation by construction. It is subsumed today, since the
1680
+ navbar band above it already covers that strip's whole height, and the next
1681
+ such cover may not be. */
1682
+ const pinned = collectPropSelectors(document.styleSheets, 'position', ['fixed', 'sticky'])
1683
+ .filter(sel => { try { bars.y.matches(sel); return true; } catch { return false; } })
1684
+ .join(', ');
1685
+ const coverList = () => (pinned ? [...document.querySelectorAll(pinned)] : []).flatMap(c => {
1686
+ if (c.classList.contains('ui-scrollbar')) return [];
1687
+ const cs = getComputedStyle(c);
1688
+ if (cs.position !== 'fixed' && cs.position !== 'sticky') return [];
1689
+ if (cs.pointerEvents === 'none') return [];
1690
+ const z = parseInt(cs.zIndex, 10); // `auto` is NaN, which counts as 0
1691
+ if (z >= SCROLLBAR_Z) return []; // paints over the bar, so it hides the bar
1692
+ return [{ el: c, z: Number.isNaN(z) ? 0 : z }];
1693
+ });
1694
+
1695
+ /* The scroller's own paint slot. A z-index orders siblings inside ONE stacking
1696
+ context, so "is this candidate below the bar" answers the wrong question: it
1697
+ says whether a cover hides the BAR, never whether it hides the SCROLLER. The
1698
+ drawer's scrim sits at 299 and the drawer itself at 300, so the scrim was
1699
+ charged against the drawer's own nav, which paints above it, and that nav lost
1700
+ its bar entirely at exactly the moment it is on screen. */
1701
+ const floorOf = el => {
1702
+ for (let n = el; n instanceof Element; n = n.parentElement) {
1703
+ const cs = getComputedStyle(n);
1704
+ if (cs.position === 'static') continue;
1705
+ const z = parseInt(cs.zIndex, 10);
1706
+ if (!Number.isNaN(z)) return z;
1707
+ }
1708
+ return 0;
1709
+ };
1710
+
1711
+ const cover = (el, axis) => {
1712
+ const box = boxOf(el);
1713
+ const lane = axis === 'y' ? [box.right - 8, box.right - SCROLLBAR_GAP]
1714
+ : [box.bottom - 8, box.bottom - SCROLLBAR_GAP];
1715
+ const spans = [];
1716
+ /* `checkVisibility` is guarded because this runs inside an rAF callback, and a
1717
+ TypeError there reaches the ring's `window.onerror` bail, which drops the
1718
+ hidden cursor for the life of the page: a missing method would cost the
1719
+ whole ambient layer, not the bar. Every other newer capability in this
1720
+ system is guarded the same way. `getClientRects().length` is the fallback,
1721
+ which answers the same question for a `display: none` subtree. */
1722
+ const shown = c => (typeof c.checkVisibility === 'function'
1723
+ ? c.checkVisibility() : c.getClientRects().length > 0);
1724
+ const floor = floorOf(el);
1725
+ for (const { el: c, z } of coverList()) {
1726
+ if (c === el || c.contains(el) || z < floor || !shown(c)) continue;
1727
+ const r = c.getBoundingClientRect();
1728
+ if (!r.width || !r.height) continue;
1729
+ const across = axis === 'y' ? r.left < lane[1] && r.right > lane[0]
1730
+ : r.top < lane[1] && r.bottom > lane[0];
1731
+ if (across) spans.push(axis === 'y' ? [r.top, r.bottom] : [r.left, r.right]);
1732
+ }
1733
+ const [lo, hi] = axis === 'y' ? [box.top, box.bottom] : [box.left, box.right];
1734
+ // each pass admits the covers the previous one brought within reach
1735
+ const run = (from, forward) => {
1736
+ for (let pass = 0; pass < spans.length; pass++) {
1737
+ let next = from;
1738
+ for (const [a, b] of spans) {
1739
+ if (forward) { if (a <= from + SCROLLBAR_CHAIN && b > next) next = b; }
1740
+ else if (b >= from - SCROLLBAR_CHAIN && a < next) next = a;
1741
+ }
1742
+ if (next === from) return from;
1743
+ from = next;
1744
+ }
1745
+ return from;
1746
+ };
1747
+ return [Math.max(0, run(lo, true) - lo), Math.max(0, hi - run(hi, false))];
1748
+ };
1749
+
1750
+ /* One place derives the thumb, because the drag's scroll rate has to agree
1751
+ with it: a rate taken from a different track length drifts away from the
1752
+ pointer over the length of the drag. */
1753
+ const metrics = (el, axis) => {
1754
+ const v = view(el, axis);
1755
+ const [cornerA, cornerB] = corner(el, axis);
1756
+ /* A DRAG FREEZES ITS OWN TRACK. `drag.rate` is captured at the press, and the
1757
+ cover is live, so chrome that docks or sticks mid-drag re-derives `track`
1758
+ under a rate that cannot follow it and the thumb slips against the pointer.
1759
+ Measured on the playground, one drag: the grab offset ran 49.72 to -5.33 to
1760
+ 109.17, a range of 114.5px on a 99.4px thumb, and the 48.0px of it owed to
1761
+ the navbar docking was predicted from `track - len` 800.55 to 744.55 before
1762
+ it was measured. Freezing is also the honest reading: the track a hand is
1763
+ holding does not change length under it. */
1764
+ const [coverA, coverB] = (drag && drag.axis === axis && drag.el === el)
1765
+ ? drag.pads : cover(el, axis);
1766
+ const padA = Math.max(cornerA, coverA), padB = Math.max(cornerB, coverB);
1767
+ const room = v - padA - padB;
1768
+ const track = Math.max(SCROLLBAR_MIN, room);
1769
+ const len = Math.min(track, Math.max(SCROLLBAR_MIN, v * v / full(el, axis)));
1770
+ return { padA, track, len, room, max: full(el, axis) - v };
1771
+ };
1772
+
1773
+ let active = root;
1774
+ let hideAt = 0;
1775
+ let drag = null;
1776
+ let frame = 0;
1777
+ let pointer = null;
1778
+
1779
+ const place = axis => {
1780
+ const bar = bars[axis];
1781
+ const el = drag ? drag.el : active;
1782
+ if (!el.isConnected || !scrolls(el, axis)) { bar.classList.remove('is-on'); return; }
1783
+ const box = boxOf(el);
1784
+ const { padA, track, len, room, max } = metrics(el, axis);
1785
+ // pinned chrome can take the whole scrollport, and a track with no room for
1786
+ // a handle is a mark, not a bar
1787
+ if (room < SCROLLBAR_MIN) { bar.classList.remove('is-on'); return; }
1788
+ const run = padA + (max > 0 ? (at(el, axis) / max) * (track - len) : 0);
1789
+ if (axis === 'y') {
1790
+ bar.style.top = `${box.top + run}px`;
1791
+ bar.style.left = `${box.right - 8}px`;
1792
+ bar.style.height = `${len}px`;
1793
+ } else {
1794
+ bar.style.left = `${box.left + run}px`;
1795
+ bar.style.top = `${box.bottom - 8}px`;
1796
+ bar.style.width = `${len}px`;
1797
+ }
1798
+ // the thumb's own box, padded: a bar that retired under a pointer reaching
1799
+ // for it would take the grab with it
1800
+ const near = pointer && (axis === 'y'
1801
+ ? pointer.x >= box.right - 8 - SCROLLBAR_HOLD && pointer.x <= box.right - SCROLLBAR_GAP + SCROLLBAR_HOLD
1802
+ && pointer.y >= box.top + run - SCROLLBAR_HOLD && pointer.y <= box.top + run + len + SCROLLBAR_HOLD
1803
+ : pointer.y >= box.bottom - 8 - SCROLLBAR_HOLD && pointer.y <= box.bottom - SCROLLBAR_GAP + SCROLLBAR_HOLD
1804
+ && pointer.x >= box.left + run - SCROLLBAR_HOLD && pointer.x <= box.left + run + len + SCROLLBAR_HOLD);
1805
+ const on = drag ? drag.axis === axis : Boolean(near) || performance.now() < hideAt;
1806
+ const wasOn = bar.classList.contains('is-on');
1807
+ bar.classList.toggle('is-on', on);
1808
+ /* THE MOVE THAT ARMS A RETIRED BAR is hit-tested while the bar still
1809
+ computes `pointer-events: none`, so `e.target` named the page and the
1810
+ ring derived nothing it can hover; a press with no further move then
1811
+ read no hover and painted neither the pressed costume nor the release
1812
+ pulse. The bar is what a real move would hit now that it takes a
1813
+ pointer, so the pointermove path is re-run with the BAR as the target.
1814
+ The bubbled event reaches four handlers, all traced: this controller's
1815
+ own move handler, which leaves `active` alone for a bar target; the
1816
+ bar's drag handler, inert without a held drag; and cursor.js's grid and
1817
+ ring handlers, of which only the ring's state moves, to `hover`.
1818
+ Dispatching on window instead would re-derive the scroller off
1819
+ `document` and hand the bar to the page. */
1820
+ if (on && !wasOn && !drag && pointer
1821
+ && document.elementFromPoint(pointer.x, pointer.y) === bar) {
1822
+ bar.dispatchEvent(new PointerEvent('pointermove',
1823
+ { pointerType: 'mouse', clientX: pointer.x, clientY: pointer.y, bubbles: true }));
1824
+ }
1825
+ };
1826
+
1827
+ /* The loop runs only while something is actually moving: a drag, or the tail of
1828
+ a scroll or a pointer move. A bar held on because the pointer rests on the
1829
+ thumb needs no frames, and `place` has already run for that state; the next
1830
+ pointermove is what changes it. An rAF that never stops is ambient motion
1831
+ with a battery cost, which this system bans everywhere else. */
1832
+ const tick = () => {
1833
+ frame = 0;
1834
+ place('y');
1835
+ place('x');
1836
+ if (drag || performance.now() < hideAt) sync();
1837
+ };
1838
+ const sync = () => { frame ||= requestAnimationFrame(tick); };
1839
+
1840
+ addEventListener('scroll', e => {
1841
+ const el = e.target === document || e.target === root ? root : e.target;
1842
+ if (!(el instanceof Element) && el !== root) return;
1843
+ if (!drag) active = el;
1844
+ hideAt = performance.now() + SCROLLBAR_IDLE;
1845
+ sync();
1846
+ }, { capture: true, passive: true });
1847
+
1848
+ addEventListener('pointermove', e => {
1849
+ pointer = { x: e.clientX, y: e.clientY };
1850
+ /* THE BAR IS ITS OWN POINTER TARGET while it is on, so a move onto the thumb
1851
+ makes `e.target` the bar and `scrollerFrom` walks bar -> body -> the root,
1852
+ which scrolls. Measured on the reference sheet: the sidebar nav's thumb sat
1853
+ at left 243 and jumped to 1432 on the first move that reached it, and the
1854
+ press that then landed dragged the page. No inner scroller's bar could be
1855
+ grabbed, and the native one is already hidden. A move ON the bar says
1856
+ nothing about which scroller is under the pointer: the bar belongs to
1857
+ `active` by construction, so leave it alone. */
1858
+ const onBar = e.target instanceof Element && e.target.classList.contains('ui-scrollbar');
1859
+ if (!drag && !onBar) {
1860
+ const el = scrollerFrom(e.target);
1861
+ if (el !== active) active = el;
1862
+ }
1863
+ // the move is what arms the timer: a resting pointer retires the bar
1864
+ hideAt = performance.now() + SCROLLBAR_IDLE;
1865
+ sync();
1866
+ }, { passive: true });
1867
+
1868
+ /* `pointer` was write-only, and the Y bar's hold band runs to `innerWidth + 8`,
1869
+ so a pointer leaving the window to the right exits THROUGH it and its last
1870
+ move is inside it: the bar then stayed lit for the life of the page. Measured
1871
+ parked at x 1439, still on after 3s. The ring's own host does the same thing
1872
+ for the same reason. */
1873
+ document.documentElement.addEventListener('pointerleave', () => { pointer = null; sync(); });
1874
+
1875
+ addEventListener('resize', () => { radii = new WeakMap(); sync(); });
1876
+
1877
+ for (const axis of ['y', 'x']) {
1878
+ bars[axis].addEventListener('pointerdown', e => {
1879
+ const el = active;
1880
+ if (!scrolls(el, axis)) return;
1881
+ if (e.pointerType === 'mouse' && e.button !== 0) return; // middle and right are not a drag
1882
+ if (drag) return; // one bar at a time: a second press would
1883
+ e.preventDefault(); // orphan the first bar's capture and is-drag
1884
+ const m = metrics(el, axis);
1885
+ // a track with no room for a handle cannot be dragged: without this the
1886
+ // `Math.max(SCROLLBAR_MIN, room)` clamp makes `track - len` 0 and one pixel
1887
+ // of travel scrolls the whole document
1888
+ if (m.room < SCROLLBAR_MIN) return;
1889
+ bars[axis].setPointerCapture(e.pointerId);
1890
+ bars[axis].classList.add('is-drag');
1891
+ drag = {
1892
+ axis, el,
1893
+ pads: cover(el, axis), // frozen for the drag, see `metrics`
1894
+ from: at(el, axis),
1895
+ origin: axis === 'y' ? e.clientY : e.clientX,
1896
+ // one pixel of thumb travel is this much scroll
1897
+ rate: m.max / Math.max(1, m.track - m.len),
1898
+ };
1899
+ sync();
1900
+ });
1901
+ bars[axis].addEventListener('pointermove', e => {
1902
+ if (!drag || drag.axis !== axis) return;
1903
+ const now = axis === 'y' ? e.clientY : e.clientX;
1904
+ const to = drag.from + (now - drag.origin) * drag.rate;
1905
+ if (axis === 'y') drag.el.scrollTop = to; else drag.el.scrollLeft = to;
1906
+ hideAt = performance.now() + SCROLLBAR_IDLE;
1907
+ sync();
1908
+ });
1909
+ const end = () => {
1910
+ if (!drag || drag.axis !== axis) return;
1911
+ drag = null;
1912
+ bars[axis].classList.remove('is-drag');
1913
+ hideAt = performance.now() + SCROLLBAR_IDLE;
1914
+ sync();
1915
+ };
1916
+ bars[axis].addEventListener('pointerup', end);
1917
+ bars[axis].addEventListener('pointercancel', end);
1918
+ }
1919
+
1920
+ sync();
1921
+ }
1922
+
1923
+ /* ============================= dropzone =========================== */
1924
+
1925
+ /* The native input stays the accessible control and the label keeps opening the
1926
+ picker, but the input no longer covers the zone: a stretched one paints the
1927
+ platform pointer straight through the ring's `cursor: none`, because its UA
1928
+ shadow tree is out of the page's reach. The zone takes the drop instead and
1929
+ hands the files over. Drag counting is by depth, since a dragleave fires for
1930
+ every child the pointer crosses. */
1931
+ export function initDropzone() {
1932
+ document.querySelectorAll('.dropzone').forEach(zone => {
1933
+ const input = zone.querySelector('.dropzone-input');
1934
+ if (!input || !once(zone, 'uiDropzone')) return;
1935
+ let depth = 0;
1936
+ const clear = () => { depth = 0; zone.classList.remove('is-over'); };
1937
+
1938
+ /* a disabled input takes no files from the keyboard, so the zone must not
1939
+ take them from the pointer either: refusing the dragenter and dragover
1940
+ defaults also keeps the browser from marking the dead zone a target */
1941
+ zone.addEventListener('dragenter', e => {
1942
+ if (input.disabled) return;
1943
+ e.preventDefault();
1944
+ if (++depth === 1) zone.classList.add('is-over');
1945
+ });
1946
+ zone.addEventListener('dragover', e => {
1947
+ if (input.disabled) return;
1948
+ e.preventDefault();
1949
+ });
1950
+ zone.addEventListener('dragleave', () => { if (--depth <= 0) clear(); });
1951
+ zone.addEventListener('drop', e => {
1952
+ if (input.disabled) { clear(); return; } // a drag that began live can end disabled
1953
+ e.preventDefault();
1954
+ clear();
1955
+ if (!e.dataTransfer?.files.length) return;
1956
+ input.files = e.dataTransfer.files;
1957
+ input.dispatchEvent(new Event('change', { bubbles: true }));
1958
+ });
1959
+ });
1960
+ }
1961
+
1962
+ /* ============================== boot ============================== */
1963
+
1964
+ export function init() {
1965
+ initViewportWidth();
1966
+ initTabs();
1967
+ initNavbar();
1968
+ initSidebar();
1969
+ initDrawer();
1970
+ initToggles();
1971
+ initDisclosures();
1972
+ initSelect();
1973
+ initPalette();
1974
+ initTooltip();
1975
+ initPopovers();
1976
+ initModal();
1977
+ initDismiss();
1978
+ initTheme();
1979
+ initSticky();
1980
+ initReveal();
1981
+ initChart();
1982
+ initCardButtons();
1983
+ initTables();
1984
+ initCodeCopy();
1985
+ initTypedPlaceholder();
1986
+ initScrollFade();
1987
+ initScrollbars();
1988
+ initDropzone();
1989
+ }
1990
+
1991
+ // a module script is deferred, so the document is parsed by the time this runs
1992
+ init();