PageSourceSearch

https://app.typingagent.com/extensions/reports/hub/js/yeti-report.js?v=1790234509

js typingagent.com collected 2026-09-24 10:45:32 UTC 140,848 bytes, 3,192 lines download raw bytes

1/**
2 * ╔══════════════════════════════════════════════════════════════╗
3 * ║  YETI REPORT KIT — JavaScript Utilities                    ║
4 * ║  Version: 1.6.4                                            ║
5 * ║  Zero dependencies (optional SheetJS for .xlsx export).    ║
6 * ║                                                            ║
7 * ║  v1.6.4 — Stored student name sorting (opt-in type)       ║
8 * ║                                                            ║
9 * ║  v1.6.3 — 'not-assigned' status vocabulary key             ║
10 * ║    • STATUS_VOCAB/STATUS_RANK gain 'not-assigned' so a     ║
11 * ║      student with no curriculum grant is distinguishable   ║
12 * ║      from one who simply hasn't started. Additive only.    ║
13 * ║                                                            ║
14 * ║  v1.6.2 — Async export builders                            ║
15 * ║    • initExportMenu awaits Promise-based sheet builders    ║
16 * ║      with loading, success, and error feedback.            ║
17 * ║                                                            ║
18 * ║  v1.6.0 — Shared report controls (§21 + report-controls.css)║
19 * ║    • YR.initReportTabs — one accessible tablist for every  ║
20 * ║      report's Table/Graph view switch: exclusive panels    ║
21 * ║      via the `hidden` attribute, aria-selected + roving    ║
22 * ║      tabindex, Arrow/Home/End keys, idempotent re-init.    ║
23 * ║    • report-controls.css — shared control tokens/classes   ║
24 * ║      (.yr-report-context/-card-head/-controls/-search/     ║
25 * ║      -tabs/-segment), loaded shell-wide by YetiReportAsset ║
26 * ║      and allow-listed in ajax-nav.js SHELL_CSS_PATHS.      ║
27 * ║                                                            ║
28 * ║  v1.5.1 — Opt-in measured inline-detail animation          ║
29 * ║    • createDetailManager measured height + state callback ║
30 * ║                                                            ║
31 * ║  v1.5.0 — Reports V2 components (Report 1)                  ║
32 * ║    • YR.charts — sparkHTML/hbar/line/scatter/heatmap (SVG) ║
33 * ║    • YR.sortTable type-aware comparators (number/date/     ║
34 * ║      status/string) + aria-sort; STATUS_RANK/_comparatorFor║
35 * ║    • YR.statusHTML master §9 vocabulary (legacy keys kept) ║
36 * ║                                                            ║
37 * ║  v1.4.2 — Persistent modal registry (#2394)                ║
38 * ║    • YR.registerPersistentModal(name, api) — modals self-  ║
39 * ║      register once; ajax-nav.js runHydrators() pulls the   ║
40 * ║      registry after every swap. Replaces per-view persist()║
41 * ║      boot lists. See YR.persistRegisteredModals().         ║
42 * ║                                                            ║
43 * ║  v1.4.1 — Mix-up filter + miss-chip variant                ║
44 * ║    • YR.filterMixups(arr, opts) — exclude unactionable     ║
45 * ║      chips (no `from`), apply minCount threshold with      ║
46 * ║      fallback for sparse data, sort by count, cap.         ║
47 * ║    • YR.mixupChipHTML now renders a "miss" variant when    ║
48 * ║      `t` is empty (light amber tint, "missed" label,       ║
49 * ║      `.is-miss` class). Misses are a different error type  ║
50 * ║      from substitutions and now read as a distinct chip.   ║
51 * ║                                                            ║
52 * ║  v1.4.0 — Cross-report consistency components              ║
53 * ║    • YR.initDropdown(config) — styled custom dropdown      ║
54 * ║      Replaces native <select> with a fully styled,         ║
55 * ║      keyboard-navigable, ARIA-compliant popup. Use for     ║
56 * ║      class pickers, student pickers, filters. (§18)        ║
57 * ║    • YR.skeletonStatCard(tone) — skeleton stat card HTML   ║
58 * ║      YR.skeletonTableRow(n, opts) — skeleton table row     ║
59 * ║      Use during async filter changes / data fetch. (§19)   ║
60 * ║    • YR.initExportMenu(config) — Export ▼ dropdown menu    ║
61 * ║      Wraps YR.exportXLSX / YR.exportCSV / window.print     ║
62 * ║      with consistent UI + filename convention. (§20)       ║
63 * ║                                                            ║
64 * ║  v1.3.4 — Table pagination                                 ║
65 * ║    • YR.initPagination() — client-side pager with windowed ║
66 * ║      page numbers, ellipses, page-size selector            ║
67 * ║    • YR.applySortToData() — re-sort helper for filter flow ║
68 * ║    • YR.sortTable now resets state.page = 1 if defined     ║
69 * ║                                                            ║
70 * ║  v1.3.3 — Documentation sync (no behavior changes)         ║
71 * ║    • SheetJS dependency explicitly documented below        ║
72 * ║    • Dev guide updated with Excel export prerequisites     ║
73 * ║                                                            ║
74 * ║  v1.3.2 — Real Excel export via SheetJS                    ║
75 * ║    • YR.exportXLSX() — native .xlsx writer (multi-sheet)   ║
76 * ║    • Auto-sized columns, frozen header row, styled header  ║
77 * ║    • Requires xlsx.full.min.js loaded on page              ║
78 * ║                                                            ║
79 * ║  v1.3.1 — Participation-aware summary averages             ║
80 * ║    • YR.computeSummary() now excludes students with        ║
81 * ║      sessions=0 from avgWpm/avgAcc/avgStars                ║
82 * ║    • New fields: participating, notStarted                 ║
83 * ║    • Data shape: add `participated` bool or let function   ║
84 * ║      infer from `sessions` > 0                             ║
85 * ║                                                            ║
86 * ║  v1.3.0 — Mobile compliance (iPad/iPhone fixes)            ║
87 * ║    • YR.lockBody() / YR.unlockBody() — iOS-safe scroll     ║
88 * ║      lock using position:fixed + scrollY preservation      ║
89 * ║    • Sidebar mobile overlay uses lockBody/unlockBody       ║
90 * ║    • Accessibility modal uses lockBody/unlockBody          ║
91 * ║    • Backward compatible — no HTML or API changes          ║
92 * ║                                                            ║
93 * ║  v1.2.0 — Layout init functions + accessibility            ║
94 * ║    • YR.initSidebar()  — generates sidebar, collapse,      ║
95 * ║                          hamburger, mobile overlay          ║
96 * ║    • YR.initUserMenu() — popup menu with keyboard nav      ║
97 * ║    • YR.initAccessibility() — modal, toggles, cookies      ║
98 * ║    • YR.headScript()   — inline <head> flash prevention    ║
99 * ║    • Cookie helpers for preference persistence             ║
100 * ╚══════════════════════════════════════════════════════════════╝
101 *
102 * Usage in Yii2 view:
103 *   <link rel="stylesheet" href="/css/yeti-report.css">
104 *   <script src="/js/yeti-report.js" defer></script>
105 *
106 *   <!-- Optional: required ONLY if calling YR.exportXLSX() -->
107 *   <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
108 *
109 *   <script>
110 *     YR.initSidebar({
111 *       user: 'Agent Gadgets',
112 *       school: 'Citi School 5',
113 *       initials: 'AG',
114 *       activePage: 'reports',
115 *       logoSrc: '/images/TA-logo.svg'
116 *     });
117 *     YR.initUserMenu();
118 *     YR.initAccessibility();
119 *   </script>
120 */
121
122// ═══════════════════════════════════════════════════════════════
123// §1 — TIER SYSTEM
124// Central logic for mapping values to color tiers.
125// Used by all components: pills, bars, badges, finger cells.
126// ═══════════════════════════════════════════════════════════════
127
128const YR = {};
129
130/**
131 * Map an accuracy percentage to a tier name
132 * @param {number} value — 0-100
133 * @param {object} [thresholds] — override defaults
134 * @returns {string} 'excellent'|'good'|'ok'|'warn'|'caution'|'danger'
135 */
136YR.accTier = function (value, thresholds) {
137  const t = Object.assign(
138    { excellent: 95, good: 90, ok: 85, warn: 80, caution: 70 },
139    thresholds
140  );
141  if (value >= t.excellent) return 'excellent';
142  if (value >= t.good) return 'good';
143  if (value >= t.ok) return 'ok';
144  if (value >
144= t.warn) return 'warn';
145  if (value >= t.caution) return 'caution';
146  return 'danger';
147};
148
149/**
150 * Map a finger strength percentage to a finger tier class
151 * @param {number} value — 0-100
152 * @returns {string} 'yr-f-excellent'|'yr-f-good'|...|'yr-f-danger'
153 */
154YR.fingerTier = function (value) {
155  if (value >= 90) return 'yr-f-excellent';
156  if (value >= 80) return 'yr-f-good';
157  if (value >= 70) return 'yr-f-ok';
158  if (value >= 60) return 'yr-f-warn';
159  return 'yr-f-danger';
160};
161
162/**
163 * Map WPM to a CSS color variable
164 * @param {number} wpm
165 * @returns {string} CSS var() value
166 */
167YR.wpmColor = function (wpm) {
168  if (wpm >= 55) return 'var(--yr-excellent)';
169  if (wpm >= 45) return 'var(--yr-blue)';
170  if (wpm >= 35) return 'var(--yr-warn)';
171  return 'var(--yr-danger)';
172};
173
174
175// ═══════════════════════════════════════════════════════════════
176// §2 — HTML BUILDERS
177// Reusable snippet generators. Return HTML strings.
178// ═══════════════════════════════════════════════════════════════
179
180/**
181 * Render 3 stars (filled/empty)
182 * @param {number} count — 0-3
183 * @returns {string} HTML
184 */
185YR.starsHTML = function (count) {
186  let html = '';
187  for (let i = 0; i < 3; i++) {
188    html += i < count
189      ? '<span class="yr-star-on">★</span>'
190      : '<span class="yr-star-off">★</span>';
191  }
192  return html;
193};
194
195/**
196 * Stars as plain text (for CSV export)
197 */
198YR.starsText = function (count) {
199  return '★'.repeat(count) + '☆'.repeat(3 - count);
200};
201
202/**
203 * Student avatar circle
204 * @param {string} name — full name
205 * @param {string} color — hex or CSS color
206 * @param {string} [size=''] — 'lg' for large
207 */
208YR.avatarHTML = function (name, color, size) {
209  // Guard against a blank/missing name so the avatar never renders empty
210  // (QA #2247). Take up to two initials; fall back to '?' when nothing usable.
211  const clean = String(name == null ? '' : name).trim();
212  const initials = clean
213    ? clean.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2)
214    : '?';
215  const cls = size === 'lg' ? 'yr-avatar yr-avatar-lg' : 'yr-avatar';
216  return `<div class="${cls}" style="background:${color}">${initials}</div>`;
217};
218
219/**
220 * Accuracy pill
221 * @param {number} value — 0-100
222 */
223YR.accPillHTML = function (value) {
224  return `<span class="yr-pill" data-tier="${YR.accTier(value)}">${value}%</span>`;
225};
226
227/**
228 * WPM cell with mini bar
229 * @param {number} wpm
230 * @param {number} [max=70] — scale reference
231 */
232YR.wpmCellHTML = function (wpm, max) {
233  max = max || 70;
234  const pct = Math.min(100, (wpm / max) * 100);
235  const color = YR.wpmColor(wpm);
236  return `<div class="yr-wpm-cell">
237    <span class="yr-wpm-val" style="color:${color}">${wpm}</span>
238    <div class="yr-bar-track w60">
239      <div class="yr-bar-fill" style="width:${pct}%;background:${color}"></div>
240    </div>
241  </div>`;
242};
243
244/**
245 * Problem keys mini badges
246 * @param {string[]} topKeys — first 4 keys
247 * @param {number} totalCount — total problem key count
248 */
249YR.problemKeysHTML = function (topKeys, totalCount) {
250  if (totalCount === 0) {
251    return '<span style="font-size:12px" class="yr-c-muted">None</span>';
252  }
253  let html = '<div class="yr-key-list">';
254  topKeys.slice(0, 4).forEach(k => {
255    html += `<div class="yr-key-mini yr-bg-caution">${k}</div>`;
256  });
257  if (totalCount > 4) {
258    html += `<span class="yr-key-more">+${totalCount - 4}</span>`;
259  }
260  html += '</div>';
261  return html;
262};
263
264/**
265 * Status cell with dot. Renders the master §9 cross-report vocabulary
266 * (active, improving, on-track, cooling, stuck, inactive, not-started,
267 * complete) plus the three legacy keys (completed | working | not_started),
268 * which render exactly as before for existing reports.
269 *
270 * @param {string} status — a vocabulary key (see above).
271 */
272YR.STATUS_VOCAB = {
273  // master §9
274  'active':      { color: 'var(--yr-green)', label: 'Active' },
275  'improving':   { color: 'var(--yr-blue)',  label: 'Improving' },
276  'on-track':    { color: 'var(--yr-green)', label: 'On track' },
277  'cooling':     { color: 'var(--yr-warn, #F0A202)', label: 'Cooling off' },
278  'stuck':       { color: 'var(--yr-warn, #F0A202)', label: 'Stuck' },
279  'inactive':    { color: 'var(--yr-danger, #D64545)', label: 'Inactive' },
280  'not-started': { color: '#cdd5de', label: 'Not started' },
281  'not-assigned': { color: '#cdd5de', label: 'Not assigned' },
282  'in-progress': { color: 'var(--yr-blue)', label: 'In progress' },
283  'complete':    { color: 'var(--yr-green)', label: 'Complete' },
284  // legacy keys (unchanged)
285  'completed':   { color: 'var(--yr-blue)', label: 'Completed' },
286  'not_started': { color: '#cdd5de', label: 'Not Started' },
287  'working':     { color: 'var(--yr-green)', label: 'Working' }
288};
289YR.statusHTML = function (status) {
290  var s = YR.STATUS_VOCAB[status] || YR.STATUS_VOCAB['working'];
291  return '<div class="yr-status">' +
292    '<div class="yr-dot" style="background:' + s.color + '"></div>' + s.label +
293    '</div>';
294};
295
296/**
297 * Key accuracy row (for detail panels)
298 * @param {object} key — { k, acc, ms }
299 */
300YR.keyRowHTML = function (key) {
301  const tier = YR.accTier(key.acc);
302  return `<div class="yr-key-row">
303    <div class="yr-key-badge yr-bg-${tier}">${key.k}</div>
304    <div class="yr-bar-track full" style="height:4px">
305      <div class="yr-bar-fill yr-bg-${tier}" data-width="${key.acc}" style="width:0%"></div>
306    </div>
307    <div class="yr-key-pct">${key.acc}%</div>
308    ${key.ms ? `<div class="yr-key-time">${key.ms}ms</div>` : ''}
309  </div>`;
310};
311
312/**
313 * Mix-up chip — renders an intended-vs-typed pair with count.
314 *
315 * Two variants based on whether `mixup.t` (what they typed) is present:
316 *   - Substitution (both `f` and `t`): "d → a 3×"
317 *   - Miss          (only `f`, `t` empty): "d → missed 2×"
318 *     Rendered with .is-miss class — light amber tint, "missed"
319 *     label in the to-position, aria-label + title for a11y.
320 *     A miss means the student was expected to type `f` but typed
321 *     nothing (skipped / deleted / paused out); it's a distinct and
322 *     actionable error type vs. a substitution.
323 *
324 * Chips with no `from` are unactionable (we don't know what the
325 * student was aiming for) and should be excluded upstream via
326 * YR.filterMixups — this function still renders them gracefully
327 * but the result is hard to read.
328 *
329 * @param {object} mixup — { f, t, c }
330 * @param {string} [size=''] — 'lg' for large
331 */
332YR.mixupChipHTML = function (mixup, size) {
333  var isMiss = !mixup.t || String(mixup.t).length === 0;
334  var classes = 'yr-mixup-chip';
335  if (size === 'lg') classes += ' lg';
336  if (isMiss) classes += ' is-miss';
337
338  var toCell = isMiss
339    ? '<span class="yr-mixup-to yr-mixup-miss-label">missed</span>'
340    : '<span class="yr-mixup-to">' + mixup.t + '</span>';
341  var ariaAttrs = isMiss
342    ? ' aria-label="Missed key: ' + mixup.f + ', ' + mixup.c + ' times" title="Missed keystroke"'
343    : ' aria-label="' + mixup.f + ' typed as ' + mixup.t + ', ' + mixup.c + ' times"';
344
345  return '<div class="' + classes + '"' + ariaAttrs + '>' +
346    '<span class="yr-mixup-from">' + mixup.f + '</span>' +
347    '<span class="yr-mixup-arrow">→</span>' +
348    toCell +
349    '<span class="yr-mixup-count">' + mixup.c + '×</span>' +
350  '</div>';
351};
352
353/**
354 * Filter and rank mix-ups for the "Common Mix-Ups" card.
355 *
356 * Rules (all overridable via opts):
357 *   1. Exclude chips with empty `from` — we don't know what the
358 *      student was aiming for, so there's no teaching action.
359 *   2. Sort remaining chips by count desc.
360 *   3. Apply minCount threshold (default 2): a single occurrence
361 *      is a typo, not a pattern; the card should surface patterns.
362 *   4. Fallback: if filtering leaves fewer than `fallbackThreshold`
363 *      chips, drop the threshold to `fallbackMin` so the card isn't
364 *      empty for newer students with sparse data.
365 *   5. Cap at `max` chips.
366 *
367 * Chips where `to` is empty (missed keys) are KEPT — they're a
368 * distinct, actionable error type and YR.mixupChipHTML renders
369 * them with the .is-miss variant.
370 *
371 * @param {Array<{f, t, c}>} mixups
372 * @param {object} [opts]
373 * @param {number} [opts.minCount=2]            — primary threshold
374 * @param {number} [opts.max=8]                 — chip count cap
375 * @param {number} [opts.fallbackMin=1]         — relaxed threshold
376 * @param {number} [opts.fallbackThreshold=3]   — when to relax
377 * @returns {Array<{f, t, c}>} filtered + sorted + capped
378 */
379YR.filterMixups = function (mixups, opts) {
380  opts = opts || {};
381  var minCount          = opts.minCount          != null ? opts.minCount          : 2;
382  var max               = opts.max               != null ? opts.max               : 8;
383  var fallbackMin       = opts.fallbackMin       != null ? opts.fallbackMin       : 1;
384  var fallbackThreshold = opts.fallbackThreshold != null ? opts.fallbackThreshold : 3;
385
386  if (!Array.isArray(mixups)) return [];
387
388  // 1. Exclude chips with no `from`
389  var usable = mixups.filter(function (m) {
390    return m && m.f != null && String(m.f).length > 0;
391  });
392
393  // 2. Sort by count desc
394  usable.sort(function (a, b) { return (b.c || 0) - (a.c || 0); });
395
396  // 3. Apply primary threshold
397  var primary = usable.filter(function (m) { return (m.c || 0) >= minCount; });
398
399  // 4. Fallback if too few
400  var result = primary.length >= fallbackThreshold
401    ? primary
402    : usable.filter(function (m) { return (m.c || 0) >= fallbackMin; });
403
404  // 5. Cap
405  return result.slice(0, max);
406};
407
408/**
409 * Finger cell (for finger map)
410 * @param {number} pct — accuracy percentage
411 * @param {string} label — e.g. 'L Pinky'
412 * @param {boolean} [compact=false]
413 */
414YR.fingerCellHTML = function (pct, label, compact) {
415  const tier = YR.fingerTier(pct);
416  const cls = compact ? 'yr-finger-cell compact' : 'yr-finger-cell';
417  return `<div class="${cls} ${tier}">
418    <div class="yr-f-pct">${pct}%</div>
419    <div class="yr-f-lbl">${label}</div>
420  </div>`;
421};
422
423/**
424 * Complete finger grid (compact, for detail panels)
425 * @param {object} f — { lp, lr, lm, li, ri, rm, rr, rp, th }
426 */
427YR.fingerGridHTML = function (f) {
428  return `<div class="yr-finger-grid">
429    ${YR.fingerCellHTML(f.lp, 'L Pinky', true)}
430    ${YR.fingerCellHTML(f.lr, 'L Ring', true)}
431    ${YR.fingerCellHTML(f.lm, 'L Mid', true)}
432    ${YR.fingerCellHTML(f.li, 'L Idx', true)}
433    ${YR.fingerCellHTML(f.ri, 'R Idx', true)}
434    ${YR.fingerCellHTML(f.rm, 'R Mid', true)}
435    ${YR.fingerCellHTML(f.rr, 'R Ring', true)}
436    ${YR.fingerCellHTML(f.rp, 'R Pinky', true)}
437    <div class="yr-finger-cell compact yr-finger-thumb ${YR.fingerTier(f.th)}">
438      <div class="yr-f-pct">${f.th}%</div>
439      <div class="yr-f-lbl">Thumbs</div>
440    </div>
441  </div>`;
442};
443
444/**
445 * Complete finger map (full size, for individual report)
446 * @param {object} f — { lp, lr, lm, li, ri, rm, rr, rp, th }
447 * @param {number} [cellWidth=110] — px width per cell
448 */
449YR.fingerMapHTML = function (f, cellWidth) {
450  const w = cellWidth || 110;
451  return `<div class="yr-finger-map">
452    <div class="yr-finger-row">
453      <div style="width:${w}px">${YR.fingerCellHTML(f.lp, 'L Pinky')}</div>
454      <div style="width:${w}px">${YR.fingerCellHTML(f.lr, 'L Ring')}</div>
455      <div style="width:${w}px">${YR.fingerCellHTML(f.lm, 'L Middle')}</div>
456      <div style="width:${w}px">${YR.fingerCellHTML(f.li, 'L Index')}</div>
457      <div style="width:${w}px">${YR.fingerCellHTML(f.ri, 'R Index')}</div>
458      <div style="width:${w}px">${YR.fingerCellHTML(f.rm, 'R Middle')}</div>
459      <div style="width:${w}px">${YR.fingerCellHTML(f.rr, 'R Ring')}</div>
460      <div style="width:${w}px">${YR.fingerCellHTML(f.rp, 'R Pinky')}</div>
461    </div>
462    <div class="yr-finger-row">
463      <div style="width:${w * 2 + 8}px">${YR.fingerCellHTML(f.th, 'Thumbs')}</div>
464    </div>
465  </div>`;
466};
467
468/**
469 * Chevron icon for expand/collapse
470 */
471YR.chevronSVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>';
472
473
474// ═══════════════════════════════════════════════════════════════
475// §3 — TABLE SORTING
476// ═══════════════════════════════════════════════════════════════
477
478/**
479 * Rank order for the master §9 status vocabulary — most urgent first, so a
480 * "status" comparator can sort by attention priority rather than alphabetically.
481 */
482YR.STATUS_RANK = {
483  'inactive': 0, 'stuck': 1, 'cooling': 2, 'not-started': 3, 'not_started': 3,
484  'in-progress': 4, 'on-track': 5, 'active': 6, 'working': 6, 'improving': 7, 'complete': 8, 'completed': 8,
485  // No grant for the program at all — nothing for the teacher to act on in
486  // this report, so it ranks after every state that reflects real work.
487  'not-assigned': 9
488};
489
490/**
491 * Compare stored student name fields, preserving compound surnames.
492 * @param {object} a Student row with firstname/lastname (or first/last).
493 * @param {object} b Student row with firstname/lastname (or first/last).
494 * @return {number} Last name, first name, then stable student id order.
495 */
496YR.compareStudentNames = function (a, b) {
497  var options = { sensitivity: 'base' };
498  var last = String(a.lastname == null ? (a.last || '') : a.lastname)
499    .localeCompare(String(b.lastname == null ? (b.last || '') : b.lastname), undefined, options);
500  var first = String(a.firstname == null ? (a.first || '') : a.firstname)
501    .localeCompare(String(b.firstname == null ? (b.first || '') : b.firstname), undefined, options);
502  return last || first || (Number(a.studentId || a.id) || 0) - (Number(b.studentId || b.id) || 0);
503};
504
505/**
506 * Build a stable comparator for state.col honouring an optional per-column type
507 * map (state.types: { colKey: 'number'|'date'|'status'|'string'|'student' }). With no
508 * type declared it falls back to the historic behaviour: numeric-aware only
509 * when both values are non-string, otherwise case-insensitive string compare —
510 * so every existing call site sorts exactly as before.
511 *
512 * @param {object} state sort state ({ col, asc, types? }).
513 * @return {Function} an (a,b) comparator.
514 */
515YR._comparatorFor = function (state) {
516  var col = state.col, asc = state.asc;
517  var type = (state.types && state.types[col]) || null;
518  var dir = asc ? 1 : -1;
519  return function (a, b) {
520    if (type === 'student') return YR.compareStudentNames(a, b) * dir;
521    var va = a[col], vb = b[col];
522    var an = va === null || va === undefined, bn = vb === null || vb === undefined;
523    // Missing values always sort to the bottom regardless of direction.
524    if (an && bn) return 0;
525    if (an) return 1;
526    if (bn) return -1;
527    var r;
528    if (type === 'number') {
529      r = (parseFloat(va) || 0) - (parseFloat(vb) || 0);
530    } else if (type === 'date') {
531      r = (new Date(va)).getTime() - (new Date(vb)).getTime();
532    } else if (type === 'status') {
533      var ra = YR.STATUS_RANK[va] !== undefined ? YR.STATUS_RANK[va] : 99;
534      var rb = YR.STATUS_RANK[vb] !== undefined ? YR.STATUS_RANK[vb] : 99;
535      r = ra - rb;
536    } else if (type === 'string') {
537      r = String(va).localeCompare(String(vb), undefined, { sensitivity: 'base', numeric: true });
538    } else {
539      // Legacy fallback (no type declared).
540      if (typeof va === 'string' || typeof vb === 'string') {
541        va = String(va).toLowerCase(); vb = String(vb).toLowerCase();
542      }
543      r = va < vb ? -1 : va > vb ? 1 : 0;
544    }
545    return r * dir;
546  };
547};
548
549/**
550 * Reflect the active sort onto the header row: `sorted`/`sort-desc` classes for
551 * styling and the ARIA `aria-sort` attribute for assistive tech.
552 *
553 * @param {object} state sort state ({ col, asc, colMap? }).
554 * @param {string} tableSelector table CSS selector.
555 * @return {void}
556 */
557YR._reflectSortHeaders = function (state, tableSelector) {
558  var table = document.querySelector(tableSelector);
559  if (!table) return;
560  var ths = table.querySelectorAll('thead th');
561  ths.forEach(function (th) {
562    th.classList.remove('sorted', 'sort-desc');
563    if (th.hasAttribute('aria-sort')) th.setAttribute('aria-sort', 'none');
564  });
565  var idx = (state.colMap || {})[state.col];
566  if (idx !== undefined && ths[idx]) {
567    ths[idx].classList.add('sorted');
568    if (!state.asc) ths[idx].classList.add('sort-desc');
569    ths[idx].setAttribute('aria-sort', state.asc ? 'ascending' : 'descending');
570  }
571};
572
573/**
574 * Sort an array and re-render a table.
575 * @param {object} state — { data, col, asc, renderFn, colMap?, types? }
576 * @param {string} newCol — column key to sort by
577 * @param {string} tableSelector — e.g. '#studentTable'
578 */
579YR.sortTable = function (state, newCol, tableSelector) {
580  if (state.col === newCol) {
581    state.asc = !state.asc;
582  } else {
583    state.col = newCol;
584    state.asc = true;
585  }
586
587  // If pagination is wired (state.page set by YR.initPagination), reset to
588  // page 1 on sort. Sorting from page 5 of 10 then staying on page 5 hides
589  // the user's intent (they want to see the new top of the sort).
590  if (state.page !== undefined) state.page = 1;
591
592  state.data.sort(YR._comparatorFor(state));
593  YR._reflectSortHeaders(state, tableSelector);
594  state.renderFn();
595};
596
597/**
598 * Re-apply state.col/state.asc to state.data in place.
599 * Useful when filtering rebuilds state.data from a source list — call this
600 * after replacing state.data so the data stays sorted by the active column.
601 */
602YR.applySortToData = function (state) {
603  state.data.sort(YR._comparatorFor(state));
604};
605
606
607// ═══════════════════════════════════════════════════════════════
608// §4 — EXPANDABLE DETAIL PANEL (Lazy Load)
609// ═══════════════════════════════════════════════════════════════
610
611/**
612 * Create a lazy-load panel manager
613 * @param {object} config
614 * @param {Function} config.fetchDetail — async (id) => detailData
615 * @param {Function} config.buildHTML — (summaryRow, detailData) => htmlString
616 * @param {Function} config.findSummary — (id) => summaryRow from data array
617 * @param {boolean} [config.animateBars=true] — animate .yr-bar-fill on open
618 * @param {boolean} [config.measured=false] — animate to measured content height
619 * @param {Function} [config.onStateChange] — (id, open) ARIA/UI callback
620 * @returns {object} { toggle(id), closeAll(), currentId }
621 */
622YR.createDetailManager = function (config) {
623  var cache = {};
624  var currentId = null;
625  var requestSeq = 0;
626  var toastTimer = null;
627
628  function clearToast() {
629    if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; }
630    YR.toast();
631  }
632
633  function hideToastLater(ms) {
634    if (toastTimer) clearTimeout(toastTimer);
635    toastTimer = setTimeout(function () { toastTimer = null; YR.toast(); }, ms);
636  }
637
638  function closeAll() {
639    requestSeq++; // invalidate any detail continuation still awaiting a response
640    clearToast();
641    if (currentId !== null) {
642      var panel = document.getElementById('yr-panel-' + currentId);
643      var row = document.querySelector('[data-yr-id="' + currentId + '"]');
644      if (panel) {
645        if (config.measured) panel.style.maxHeight = '0px';
646        panel.classList.remove('open');
647      }
648      if (row) row.classList.remove('expanded');
649      if (typeof config.onStateChange === 'function') config.onStateChange(currentId, false);
650      currentId = null;
651    }
652  }
653
654  async function toggle(id) {
655    var panel = document.getElementById('yr-panel-' + id);
656    var row = document.querySelector('[data-yr-id="' + id + '"]');
657    var isOpen = panel && panel.classList.contains('open');
658
659    // Close current
660    if (currentId !== null && currentId !== id) closeAll();
661
662    // Toggle off
663    if (isOpen) {
664      requestSeq++;
665      clearToast();
666      if (config.measured) panel.style.maxHeight = '0px';
667      panel.classList.remove('open');
668      row.classList.remove('expanded');
669      if (typeof config.onStateChange === 'function') config.onStateChange(id, false);
670      currentId = null;
671      return;
672    }
673
674    // Open with loading
675    var myRequest = ++requestSeq;
676    currentId = id;
677    if (row) row.classList.add('expanded');
678    panel.innerHTML = '<div class="yr-loading"><div class="yr-spinner"></div><div class="yr-loading-text">Loading details...</div></div>';
679    panel.classList.add('open');
680    if (config.measured) {
681      if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) panel.style.transition = '
681none';
682      panel.style.maxHeight = panel.scrollHeight + 'px';
683    }
684    if (typeof config.onStateChange === 'function') config.onStateChange(id, true);
685
686    try {
687      // Check cache
688      var detail;
689      if (cache[id]) {
690        detail = cache[id];
691      } else {
692        YR.toast('loading', 'Fetching data...');
693        detail = await config.fetchDetail(id);
694        if (myRequest !== requestSeq || currentId !== id || !panel.isConnected ||
695            (typeof config.isCurrent === 'function' && !config.isCurrent(id))) return;
696        cache[id] = detail;
697        YR.toast('done', 'Loaded');
698        hideToastLater(1500);
699      }
700
701      if (myRequest !== requestSeq || currentId !== id || !panel.isConnected ||
702          (typeof config.isCurrent === 'function' && !config.isCurrent(id))) return;
703
704      var summary = config.findSummary(id);
705      panel.innerHTML = config.buildHTML(summary, detail);
706      if (config.measured) {
707        requestAnimationFrame(function () { panel.style.maxHeight = panel.scrollHeight + 'px'; });
708      }
709
710      // Animate bars
711      if (config.animateBars !== false) {
712        requestAnimationFrame(function () {
713          panel.querySelectorAll('.yr-bar-fill[data-width]').forEach(function (bar) {
714            var w = bar.dataset.width;
715            bar.style.width = '0%';
716            requestAnimationFrame(function () { bar.style.width = w + '%'; });
717          });
718        });
719      }
720    } catch (err) {
721      if ((err && (err.name === 'AbortError' || err.name === 'StaleVisitError')) ||
722          myRequest !== requestSeq || currentId !== id || !panel.isConnected ||
723          (typeof config.isCurrent === 'function' && !config.isCurrent(id))) {
724        clearToast();
725        return;
726      }
727      YR.toast('error', 'Failed');
728      hideToastLater(2000);
729      panel.innerHTML =
730        '<div class="yr-loading">' +
731        '<div style="color:var(--yr-danger);font-size:14px;font-weight:600">Failed to load</div>' +
732        '<div style="font-size:12px" class="yr-c-muted">Check connection and try again</div>' +
733        '<button class="yr-btn" onclick="YR._retryDetail(' + id + ')" style="margin-top:8px">Retry</button>' +
734        '</div>';
735      if (config.measured) panel.style.maxHeight = panel.scrollHeight + 'px';
736    }
737  }
738
739  // Store toggle reference for retry
740  YR._retryDetail = toggle;
741
742  return {
743    toggle: toggle,
744    closeAll: closeAll,
745    clearCache: function () { cache = {}; requestSeq++; },
746    get currentId() { return currentId; }
747  };
748};
749
750
751// ═══════════════════════════════════════════════════════════════
752// §5 — TOAST NOTIFICATIONS
753// ═══════════════════════════════════════════════════════════════
754
755/**
756 * Show/hide the network toast
757 * @param {string} [state] — 'loading'|'done'|'error' or falsy to hide
758 * @param {string} [text]
759 */
760YR.toast = function (state, text) {
761  var el = document.getElementById('yr-toast');
762  if (!el) return;
763  if (!state) { el.classList.remove('show'); return; }
764  var dot = el.querySelector('.yr-toast-dot');
765  var txt = el.querySelector('.yr-toast-text');
766  if (dot) dot.className = 'yr-toast-dot ' + state;
767  if (txt) txt.textContent = text || '';
768  el.classList.add('show');
769};
770
771/**
772 * Toast HTML (add once to page bottom)
773 */
774YR.toastHTML = function () {
775  return '<div class="yr-toast" id="yr-toast"><div class="yr-toast-dot"></div><span class="yr-toast-text"></span></div>';
776};
777
778
779// ═══════════════════════════════════════════════════════════════
780// §6 — CSV / XLSX EXPORT
781// ═══════════════════════════════════════════════════════════════
782
783/**
784 * Export data as CSV download
785 * @param {object} config
786 * @param {string[]} config.headers — column headers
787 * @param {Array[]} config.rows — array of row arrays
788 * @param {string}
788 [config.filename] — default: 'report-YYYY-MM-DD.csv'
789 */
790YR.exportCSV = function (config) {
791  var escCSV = function (val) {
792    var str = String(val == null ? '' : val);
793    return (str.indexOf(',') >= 0 || str.indexOf('"') >= 0 || str.indexOf('\n') >= 0)
794      ? '"' + str.replace(/"/g, '""') + '"'
795      : str;
796  };
797
798  var lines = [config.headers.map(escCSV).join(',')];
799  config.rows.forEach(function (row) {
800    lines.push(row.map(escCSV).join(','));
801  });
802  var csv = lines.join('\n');
803
804  // BOM prefix for Excel UTF-8
805  var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
806  var url = URL.createObjectURL(blob);
807  var a = document.createElement('a');
808  a.href = url;
809  a.download = config.filename || ('report-' + new Date().toISOString().slice(0, 10) + '.csv');
810  document.body.appendChild(a);
811  a.click();
812  document.body.removeChild(a);
813  URL.revokeObjectURL(url);
814};
815
816/**
817 * Export data as real .xlsx file (native Excel format).
818 * Requires SheetJS loaded on the page:
819 *   <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
820 *
821 * @param {object} config
822 * @param {Array} config.sheets — array of {name, headers, rows, colWidths?}
823 *   OR for single-sheet exports, use top-level {headers, rows} instead.
824 * @param {string[]} [config.headers] — single-sheet column headers
825 * @param {Array[]} [config.rows] — single-sheet row arrays
826 * @param {string} [config.filename] — default: 'report-YYYY-MM-DD.xlsx'
827 * @param {string} [config.sheetName='Report'] — name for single-sheet workbooks
828 */
829YR.exportXLSX = function (config) {
830  if (typeof XLSX === 'undefined') {
831    console.error('YR.exportXLSX requires SheetJS. Add xlsx.full.min.js to the page.');
832    alert('Excel export unavailable — SheetJS library not loaded.');
833    return;
834  }
835
836  var wb = XLSX.utils.book_new();
837  var sheets = config.sheets || [{
838    name:      config.sheetName || 'Report',
839    headers:   config.headers,
840    rows:      config.rows,
841    colWidths: config.colWidths
842  }];
843
844  sheets.forEach(function (sheet) {
845    var data = [sheet.headers].concat(sheet.rows);
846    var ws = XLSX.utils.aoa_to_sheet(data);
847
848    // Auto-size columns (or honor explicit widths)
849    if (sheet.colWidths) {
850      ws['!cols'] = sheet.colWidths.map(function (w) { return { wch: w }; });
851    } else {
852      ws['!cols'] = sheet.headers.map(function (h, i) {
853        var maxLen = String(h).length;
854        sheet.rows.forEach(function (row) {
855          var cell = row[i] == null ? '' : String(row[i]);
856          if (cell.length > maxLen) maxLen = cell.length;
857        });
858        return { wch: Math.min(Math.max(maxLen + 2, 10), 50) };
859      });
860    }
861
862    // Freeze header row
863    ws['!freeze'] = { xSplit: 0, ySplit: 1 };
864
865    // Bold header row via cell styling
866    var range = XLSX.utils.decode_range(ws['!ref']);
867    for (var c = range.s.c; c <= range.e.c; c++) {
868      var addr = XLSX.utils.encode_cell({ r: 0, c: c });
869      if (ws[addr]) {
870        ws[addr].s = {
871          font: { bold: true, color: { rgb: 'FFFFFF' } },
872          fill: { fgColor: { rgb: '12A0DD' } },
873          alignment: { horizontal: 'left', vertical: 'center' }
874        };
875      }
876    }
877
878    XLSX.utils.book_append_sheet(wb, ws, (sheet.name || 'Sheet').slice(0, 31));
879  });
880
881  var filename = config.filename || ('report-' + new Date().toISOString().slice(0, 10) + '.xlsx');
882  XLSX.writeFile(wb, filename);
883};
884
885
886// ═══════════════════════════════════════════════════════════════
887// §7 — PRINT
888// ═══════════════════════════════════════════════════════════════
889
890/**
891 * Print the report (sets date, closes panels, triggers print)
892 * @param {object} [detailManager] — if provided, closes any open panel
893 */
894YR.printReport = function (detailManager) {
895  // Set print date
896  var dateEl = document.getElementById('yr-print-date');
897  if (dateEl) {
898    dateEl.textContent = new Date().toLocaleDateString('en-US', {
899      year: 'numeric', month: 'long', day: 'numeric'
900    });
901  }
902  // Close detail panels
903  if (detailManager) detailManager.closeAll();
904
905  window.print();
906};
907
908
909// ═══════════════════════════════════════════════════════════════
910// §8 — DROPDOWN TOGGLE
911// ═══════════════════════════════════════════════════════════════
912
913/**
914 * Toggle a dropdown and auto-close on outside click
915 * @param {Event} e — click event from trigger button
916 * @param {string} dropdownId — id of .yr-dropdown element
917 */
918YR.toggleDropdown = function (e, dropdownId) {
919  e.stopPropagation();
920  var dd = document.getElementById(dropdownId);
921  if (!dd) return;
922  dd.classList.toggle('open');
923
924  var close = function (ev) {
925    if (!dd.contains(ev.target)) {
926      dd.classList.remove('open');
927      document.removeEventListener('click', close);
928    }
929  };
930  if (dd.classList.contains('open')) {
931    setTimeout(function () { document.addEventListener('click', close); }, 0);
932  }
933};
934
935
936// ═══════════════════════════════════════════════════════════════
937// §9 — SUMMARY CARD COMPUTATION
938// ═══════════════════════════════════════════════════════════════
939
940/**
941 * Compute class-level summary stats from data array.
942 *
943 * IMPORTANT: Averages (WPM, accuracy, stars) are computed ONLY over
944 * students who have actually done a session. Students with sessions=0
945 * (or participated=false) are counted in "notStarted" but excluded
946 * from the averages so a single active student isn't diluted by
947 * zeroes from classmates who haven't started yet.
948 *
949 * @param {object[]} data — array of student summary objects
950 * @param {object} [fields] — field name overrides:
951 *   { wpm, acc, stars, status, sessions, participated }
952 *   Optional `participated` is a boolean field. If omitted,
953 *   a student is considered participating when sessions > 0.
954 * @returns {object} {
955 *   avgWpm, avgAcc, avgStars,    // averaged over participants only
956 *   completed, working,            // status breakdown (participants)
957 *   notStarted,                    // students with no sessions
958 *   participating,                 // count included in averages
959 *   total                          // total students in class
960 * }
961 */
962YR.computeSummary = function (data, fields) {
963  var f = Object.assign(
964    { wpm: 'wpm', acc: 'acc', stars: 'stars', status: 'status',
965      sessions: 'sessions', participated: 'participated' },
966    fields
967  );
968  var total = data.length;
969  if (total === 0) {
970    return { avgWpm: 0, avgAcc: 0, avgStars: '0.0',
971             completed: 0, working: 0, notStarted: 0,
972             participating: 0, total: 0 };
973  }
974
975  // Determine if a student has participated in this report's activity
976  function hasParticipated(s) {
977    // Explicit field wins if present
978    if (s[f.participated] !== undefined) return !!s[f.participated];
979    // Otherwise infer from sessions count
980    if (s[f.sessions] !== undefined) return (s[f.sessions] || 0) > 0;
981    // Fall back to: any non-zero WPM or accuracy means they played
982    return (s[f.wpm] || 0) > 0 || (s[f.acc] || 0) > 0;
983  }
984
985  var sumWpm = 0, sumAcc = 0, sumStars = 0;
986  var completed = 0, working = 0, notStarted = 0, participating = 0;
987
988  data.forEach(function (s) {
989    if (hasParticipated(s)) {
990      participating++;
991      sumWpm += s[f.wpm] || 0;
992      sumAcc += s[f.acc] || 0;
993      sumStars += s[f.stars] || 0;
994      if (s[f.status] === 'completed') completed++;
995      else working++;
996    } else {
997      notStarted++;
998    }
999  });
1000
1001  return {
1002    avgWpm:   participating > 0 ? Math.round(sumWpm / participating) : 0,
1003    avgAcc:   participating > 0 ? Math.round(sumAcc / participating) : 0,
1004    avgStars: participating > 0 ? (sumStars / participating).toFixed(1) : '0.0',
1005    completed:   completed,
1006    working:     working,
1007    notStarted:  notStarted,
1008    participating: participating,
1009    total: total
1010  };
1011};
1012
1013
1014// ═══════════════════════════════════════════════════════════════
1015// §10 — AVATAR COLOR PALETTE
1016// Consistent, accessible colors for student avatars.
1017// ═══════════════════════════════════════════════════════════════
1018
1019YR.AVATAR_COLORS = [
1020  '#139edd', '#0b7dbe', '#1abc9c', '#16a085', '#27ae60',
1021  '#2ecc71', '#f39c12', '#e67e22', '#e74c3c', '#9b59b6',
1022  '#3498db', '#2980b9', '#1abc9c', '#e74c3c', '#f39c12',
1023  '#27ae60', '#9b59b6', '#139edd', '#e67e22', '#2ecc71'
1024];
1025
1026/**
1027 * Get avatar color by index (wraps around)
1028 */
1029YR.avatarColor = function (index) {
1030  return YR.AVATAR_COLORS[index % YR.AVATAR_COLORS.length];
1031};
1032
1033
1034// ═══════════════════════════════════════════════════════════════
1035// §11 — SVG ICON LIBRARY
1036// Common icons used across reports (inline SVG strings).
1037// ═══════════════════════════════════════════════════════════════
1038
1039YR.ICONS = {
1040  bolt:       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
1041  target:     '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
1042  star:       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
1043  wrench:     '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
1044  bars:       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>',
1045  grid:       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>',
1046  hand:       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 00-4 0v5"/><path d="M14 10V4a2 2 0 00-4 0v6"/><path d="M10 10.5V5a2 2 0 00-4 0v9"/></svg>',
1047  users:      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>',
1048  download:   '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1049  printer:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>',
1050  external:   '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
1051  chevLeft:   '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>',
1052  chevDown:   '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>',
1053  chart:      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
1054  file:       '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
1055  retry:      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>',
1056};
1057
1058
1059// ═══════════════════════════════════════════════════════════════
1060// §11b — CLASS DROPDOWN
1061// ═══════════════════════════════════════════════════════════════
1062
1063/**
1064 * Initialize a class dropdown (replaces native <select>)
1065 * @param {object} config
1066 * @param {string} [config.triggerId] — ID of trigger element (default: 'classTrigger')
1067 * @param {string} [config.menuId] — ID of menu element (default: 'classMenu')
1068 * @param {string} [config.wrapperId] — ID of wrapper for outside-click (default: 'classDropdown')
1069 * @param {function} [config.onSelect] — callback(value, text, element)
1070 */
1071YR.initClassDropdown = function (config) {
1072  config = config || {};
1073  var trigger = document.getElementById(config.triggerId || 'classTrigger') ||
1074                document.querySelector('.yr-class-trigger');
1075  var menu = document.getElementById(config.menuId || 'classMenu');
1076  var wrapperId = config.wrapperId || 'classDropdown';
1077
1078  if (!trigger || !menu) return;
1079
1080  function toggle() {
1081    var opening = !menu.classList.contains('open');
1082    menu.classList.toggle('open');
1083    trigger.classList.toggle('open');
1084    if (opening) {
1085      setTimeout(function () {
1086        document.addEventListener('click', function closer(e) {
1087          if (!e.target.closest('#' + wrapperId)) {
1088            menu.classList.remove('open');
1089            trigger.classList.remove('open');
1090            document.removeEventListener('click', closer);
1091          }
1092        });
1093      }, 0);
1094    }
1095  }
1096
1097  function select(el) {
1098    var text = el.textContent.trim();
1099    var val = el.dataset.id || el.dataset.value || text;
1100    var textEl = trigger.querySelector('span') || trigger;
1101    textEl.textContent = text;
1102    menu.querySelectorAll('.yr-class-menu-item').forEach(function (i) {
1103      i.classList.remove('active');
1104    });
1105    el.classList.add('active');
1106    menu.classList.remove('open');
1107    trigger.classList.remove('open');
1108    if (config.onSelect) config.onSelect(val, text, el);
1109  }
1110
1111  trigger.addEventListener('click', toggle);
1112  menu.addEventListener('click', function (e) {
1113    var item = e.target.closest('.yr-class-menu-item');
1114    if (item) select(item);
1115  });
1116
1117  return { toggle: toggle, select: select };
1118};
1119
1120/**
1121 * Chevron SVG for the class dropdown trigger
1122 */
1123YR.classChevronSVG = '<svg class="yr-class-trigger-chevron" viewBox="0 0 10 6" fill="none"><path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1124
1125
1126// ═══════════════════════════════════════════════════════════════
1127// §11c — iOS-SAFE SCROLL LOCK
1128// iOS Safari ignores overflow:hidden on body. This uses
1129// position:fixed with scrollY preservation — the only reliable
1130// cross-browser scroll lock for modals and mobile overlays.
1131// ═══════════════════════════════════════════════════════════════
1132
1133YR._scrollY = 0;
1134YR._lockCount = 0;
1135YR._bodyLockSeq = 0;
1136YR._bodyLockTokens = YR._bodyLockTokens || {};
1137YR._legacyBodyLocks = YR._legacyBodyLocks || [];
1138YR._bodyLockPrior = null;
1139
1140/**
1141 * Resolve the active AjaxNav page identity without coupling the report kit to
1142 * ajax-nav.js's load order.
1143 *
1144 * @return {number|null} current navigation id, when available.
1145 */
1146function yrCurrentNavigationId() {
1147  if (!window.AjaxNav || typeof window.AjaxNav.currentNavigationId !== 'function') return null;
1148  return window.AjaxNav.currentNavigationId();
1149}
1150
1151/**
1152 * Apply the fixed-body lock and remember the exact inline state it replaces.
1153 *
1154 * @return {void}
1155 */
1156function yrApplyBodyLock() {
1157  var b = document.body;
1158  if (!b) return;
1159  YR._scrollY = window.scrollY || window.pageYOffset || 0;
1160  YR._bodyLockPrior = {
1161    position: b.style.position,
1162    top: b.style.top,
1163    left: b.style.left,
1164    right: b.style.right,
1165    width: b.style.width
1166  };
1167  b.style.position = 'fixed';
1168  b.style.top = '-' + YR._scrollY + 'px';
1169  b.style.left = '0';
1170  b.style.right = '0';
1171  b.style.width = '100%';
1172}
1173
1174/**
1175 * Restore body state after the final owned lock token is released.
1176 *
1177 * @param {boolean} restoreScroll whether to restore the captured scroll offset.
1178 * @return {void}
1179 */
1180function yrRestoreBodyLock(restoreScroll) {
1181  var b = document.body;
1182  if (!b) return;
1183  var prior = YR._bodyLockPrior || {};
1184  b.style.position = prior.position || '';
1185  b.style.top = prior.top || '';
1186  b.style.left = prior.left || '';
1187  b.style.right = prior.right || '';
1188  b.style.width = prior.width || '';
1189  YR._bodyLockPrior = null;
1190  if (restoreScroll !== false) window.scrollTo(0, YR._scrollY);
1191}
1192
1193/**
1194 * Acquire an owner-aware body lock. The returned release callback is
1195 * idempotent and accepts `{restoreScroll:false}` for navigation teardown.
1196 *
1197 * @param {Object} [options] lock metadata.
1198 * @return {Function} idempotent release callback.
1199 */
1200YR.acquireBodyLock = function (options) {
1201  options = options || {};
1202  var tokenId = ++YR._bodyLockSeq;
1203  var token = {
1204    tokenId: tokenId,
1205    owner: options.owner === 'shell' || options.owner === 'shell-critical' ? options.owner : 'page',
1206    id: String(options.id || 'legacy'),
1207    navigationId: options.navigationId != null ? options.navigationId : yrCurrentNavigationId(),
1208    released: false
1209  };
1210
1211  if (YR._lockCount === 0) yrApplyBodyLock();
1212  YR._bodyLockTokens[tokenId] = token;
1213  YR._lockCount++;
1214
1215  var release = function (releaseOptions) {
1216    if (token.released) return;
1217    token.released = true;
1218    delete YR._bodyLockTokens[tokenId];
1219    if (YR._lockCount > 0) YR._lockCount--;
1220    if (YR._lockCount === 0) {
1221      yrRestoreBodyLock(!releaseOptions || releaseOptions.restoreScroll !== false);
1222    }
1223  };
1224  token.release = release;
1225  release._yrToken = token;
1226  return release;
1227};
1228
1229/**
1230 * Release matching owned body-lock tokens.
1231 *
1232 * @param {Object} [criteria] owner and/or navigationId filters.
1233 * @return {number} number of released tokens.
1234 */
1235YR.releaseBodyLocks = function (criteria) {
1236  criteria = criteria || {};
1237  var released = 0;
1238  Object.keys(YR._bodyLockTokens).forEach(function (key) {
1239    var token = YR._bodyLockTokens[key];
1240    if (!token || token.released) return;
1241    if (criteria.owner && token.owner !== criteria.owner) return;
1242    if (criteria.navigationId != null && token.navigationId !== criteria.navigationId) return;
1243    token.release({ restoreScroll: criteria.restoreScroll !== false });
1244    released++;
1245  });
1246  YR._legacyBodyLocks = YR._legacyBodyLocks.filter(function (release) {
1247    return !(release && release._yrToken && release._yrToken.released);
1248  });
1249  return released;
1250};
1251
1252/**
1253 * Return non-sensitive body-lock ownership data for lifecycle diagnostics.
1254 *
1255 * @return {Array<Object>} active token summaries.
1256 */
1257YR.getBodyLockSnapshot = function () {
1258  return Object.keys(YR._bodyLockTokens).map(function (key) {
1259    var token = YR._bodyLockTokens[key];
1260    return {
1261      tokenId: token.tokenId,
1262      owner: token.owner,
1263      id: token.id,
1264      navigationId: token.navigationId
1265    };
1266  });
1267};
1268
1269/** @return {boolean} whether any owner-aware body lock is active. */
1270YR.hasActiveBodyLocks = function () {
1271  return YR._lockCount > 0;
1272};
1273
1274// Compatibility adapters. Legacy callers remain balanced via a release stack;
1275// new code should retain the callback returned by acquireBodyLock().
1276YR.lockBody = function (options) {
1277  var release = YR.acquireBodyLock(options || { owner: 'page', id: 'legacy/yr' });
1278  YR._legacyBodyLocks.push(release);
1279  return release;
1280};
1281
1282YR.unlockBody = function () {
1283  var release = null;
1284  while (YR._legacyBodyLocks.length && !release) {
1285    var candidate = YR._legacyBodyLocks.pop();
1286    if (!candidate._yrToken || !candidate._yrToken.released) release = candidate;
1287  }
1288  if (release) release();
1289};
1290
1291// Compatibility residue repair used by older AjaxNav/page cleanup callers.
1292// It now releases PAGE tokens only and never neutralizes a valid shell lock.
1293YR._forceReleaseBody = function () {
1294  YR.releaseBodyLocks({ owner: 'page', restoreScroll: false });
1295};
1296
1297
1298// ═══════════════════════════════════════════════════════════════
1299// §12 — COOKIE HELPERS
1300// ═══════════════════════════════════════════════════════════════
1301
1302YR.COOKIE_NAME = 'yr_prefs';
1303
1304YR.setCookie = function (name, value, days) {
1305  days = days || 365;
1306  var d = new Date();
1307  d.setTime(d.getTime() + days * 86400000);
1308  document.cookie = name + '=' + encodeURIComponent(JSON.stringify(value)) +
1309    ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
1310};
1311
1312YR.getCookie = function (name) {
1313  var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
1314  if (!match) return null;
1315  try { return JSON.parse(decodeURIComponent(match[1])); } catch (e) { return null; }
1316};
1317
1318
1319// ═══════════════════════════════════════════════════════════════
1320// §13 — SIDEBAR NAV CONFIG
1321// Default nav items. Override by passing navItems to initSidebar.
1322// ═══════════════════════════════════════════════════════════════
1323
1324YR.NAV_ICONS = {
1325  dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
1326  classes: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>',
1327  staff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
1328  students: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>',
1329  reports: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>',
1330  curriculum: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="12" y1="6" x2="12" y2="13"/><line x1="9" y1="10" x2="15" y2="10"/></svg>',
1331  assessments: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
1332  settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
1333  management: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
1334  help: '<svg class="yr-svg-overflow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
1335};
1336
1337YR.DEFAULT_NAV = [
1338  { key: 'dashboard',   label: 'Dashboard' },
1339  { key: 'classes',     label: 'Classes' },
1340  { key: 'staff',       label: 'Staff' },
1341  { key: 'students',    label: 'Students' },
1342  { key: 'reports',     label: 'Reports' },
1343  'separator',
1344  { key: 'curriculum',  label: 'Curriculum' },
1345  { key: 'assessments', label: 'Assessments' },
1346  { key: 'settings',    label: 'Settings' },
1347  { key: 'management',  label: 'Management' }
1348];
1349
1350YR.USER_MENU_ITEMS = [
1351  { label: 'My Profile',       icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
1352  { label: 'Account',          icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>' },
1353  { label: 'Accessibility',    icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="7" r="1.5" fill="currentColor" stroke="none"/><path d="M9 11h6"/><path d="M10 11v5l-1 2"/><path d="M14 11v5l1 2"/></svg>', id: 'a11yMenuItem' },
1354  { label: 'Demo Student',     icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M21 15l-3-3m0 0l-3 3m3-3v6"/></svg>' },
1355  { label: "Teacher's Guides", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>' },
1356  'separator',
1357  { label: 'Pro Tips',         icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
1358  { label: 'Logout',           icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', cls: 'yr-um-logout' }
1359];
1360
1361
1362// ═══════════════════════════════════════════════════════════════
1363// §14 — INIT SIDEBAR
1364// Generates sidebar HTML, wires collapse, hamburger, overlay.
1365//
1366// Config:
1367//   user      — teacher display name (string)
1368//   school    — school name (string)
1369//   initials  — 2-letter avatar (string, auto-derived if omitted)
1370//   activePage — key from NAV_ICONS to highlight (string)
1371//   logoSrc   — path to logo PNG (string)
1372//   navItems  — override YR.DEFAULT_NAV (array)
1373//   target    — container selector (default: '#sidebar' or prepend to body)
1374// ═══════════════════════════════════════════════════════════════
1375
1376YR.initSidebar = function (config) {
1377  config = config || {};
1378  var user = config.user || 'Teacher';
1379  var school = config.school || '';
1380  var initials = config.initials || user.split(' ').map(function(w) { return w[0]; }).join('').toUpperCase().slice(0, 2);
1381  var active = config.activePage || '';
1382  var logoSrc = config.logoSrc || 'TA-logo.svg';
1383  var navItems = config.navItems || YR.DEFAULT_NAV;
1384
1385  // Build nav HTML
1386  var navHTML = '';
1387  navItems.forEach(function (item) {
1388    if (item === 'separator') {
1389      navHTML += '<div class="yr-nav-sep" role="separator"></div>';
1390      return;
1391    }
1392    var icon = YR.NAV_ICONS[item.key] || '';
1393    var isActive = item.key === active;
1394    var href = item.href || '#';
1395    navHTML += '<a href="' + href + '" data-label="' + item.label + '"' +
1396      (isActive ? ' class="active" aria-current="page"' : '') +
1397      '>' + icon + '<span class="yr-nav-label">' + item.label + '</span></a>';
1398  });
1399
1400  // Build full sidebar
1401  var html =
1402    '<div class="yr-sidebar-logo">' +
1403      '<img class="yr-logo-img" src="' + logoSrc + '" alt="Typing Agent">' +
1404      '<div class="yr-logo-compact" aria-hidden="true">' +
1405        '<span class="yr-lc-t">T</span><span class="yr-lc-a">a</span><span class="yr-lc-pipe">|</span>' +
1406      '</div>' +
1407      '<button class="yr-sidebar-toggle" id="sidebarToggle" aria-label="Collapse sidebar" aria-expanded="true">' +
1408        '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
1408' +
1409      '</button>' +
1410    '</div>' +
1411    '<nav class="yr-sidebar-nav" aria-label="Primary">' + navHTML + '</nav>' +
1412    '<div class="yr-sidebar-footer">' +
1413      '<a href="#" class="yr-sf-help" data-label="Solution Center">' +
1414        YR.NAV_ICONS.help +
1415        '<span class="yr-nav-label">Solution Center</span>' +
1416      '</a>' +
1417      '<div class="yr-sf-user" id="userMenuTrigger" role="button" tabindex="0" ' +
1418        'aria-haspopup="menu" aria-expanded="false" aria-label="Account menu for ' + user + '">' +
1419        '<div class="yr-sf-avatar" aria-hidden="true">' + initials + '</div>' +
1420        '<div class="yr-sf-info">' +
1421          '<div class="yr-sf-name">' + user + '</div>' +
1422          '<div class="yr-sf-role">' + school + '</div>' +
1423        '</div>' +
1424        '<span class="yr-sf-chevron" aria-hidden="true"><svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 5l4-4 4 4"/></svg></span>' +
1425        '<div class="yr-user-menu" id="userMenu" role="menu" aria-label="Account menu"></div>' +
1426      '</div>' +
1427    '</div>';
1428
1429  // Insert into DOM
1430  var sidebar = document.getElementById('sidebar');
1431  if (!sidebar) {
1432    sidebar = document.createElement('aside');
1433    sidebar.id = 'sidebar';
1434    sidebar.className = 'yr-sidebar';
1435    sidebar.setAttribute('aria-label', 'Main navigation');
1436    document.body.insertBefore(sidebar, document.body.firstChild);
1437  }
1438  sidebar.innerHTML = html;
1439
1440  // Add overlay if missing
1441  if (!document.getElementById('sidebarOverlay')) {
1442    var overlay = document.createElement('div');
1443    overlay.className = 'yr-sidebar-overlay';
1444    overlay.id = 'sidebarOverlay';
1445    overlay.setAttribute('aria-hidden', 'true');
1446    sidebar.after(overlay);
1447  }
1448
1449  // Wire collapse toggle
1450  var toggle = document.getElementById('sidebarToggle');
1451  if (toggle) {
1452    toggle.addEventListener('click', function () {
1453      var collapsed = sidebar.classList.toggle('collapsed');
1454      toggle.setAttribute('aria-expanded', String(!collapsed));
1455      toggle.setAttribute('aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar');
1456    });
1457  }
1458
1459  // Wire hamburger (if present in main content)
1460  var hamburger = document.getElementById('hamburgerBtn');
1461  var sidebarOverlay = document.getElementById('sidebarOverlay');
1462
1463  function openMobile() {
1464    sidebar.classList.add('mobile-open');
1465    if (sidebarOverlay) sidebarOverlay.classList.add('show');
1466    if (hamburger) hamburger.setAttribute('aria-expanded', 'true');
1467    YR.lockBody();
1468    var firstLink = sidebar.querySelector('.yr-sidebar-nav a');
1469    if (firstLink) firstLink.focus();
1470  }
1471
1472  function closeMobile() {
1473    sidebar.classList.remove('mobile-open');
1474    if (sidebarOverlay) sidebarOverlay.classList.remove('show');
1475    if (hamburger) hamburger.setAttribute('aria-expanded', 'false');
1476    YR.unlockBody();
1477    if (hamburger) hamburger.focus();
1478  }
1479
1480  if (hamburger) {
1481    hamburger.addEventListener('click', function () {
1482      sidebar.classList.contains('mobile-open') ? closeMobile() : openMobile();
1483    });
1484  }
1485  if (sidebarOverlay) {
1486    sidebarOverlay.addEventListener('click', closeMobile);
1487  }
1488
1489  // Close mobile on nav click
1490  sidebar.querySelectorAll('.yr-sidebar-nav a, .yr-sf-help').forEach(function (link) {
1491    link.addEventListener('click', function () {
1492      if (sidebar.classList.contains('mobile-open')) closeMobile();
1493    });
1494  });
1495
1496  // Store references for other modules
1497  YR._sidebar = sidebar;
1498  YR._closeMobile = closeMobile;
1499
1500  return { sidebar: sidebar, openMobile: openMobile, closeMobile: closeMobile };
1501};
1502
1503
1504// ═══════════════════════════════════════════════════════════════
1505// §15 — INIT USER MENU
1506// Generates menu items, wires keyboard nav, focus management.
1507// Call AFTER initSidebar().
1508// ═══════════════════════════════════════════════════════════════
1509
1510YR.initUserMenu = function (config) {
1511  config = config || {};
1512  var items = config.items || YR.USER_MENU_ITEMS;
1513  var trigger = document.getElementById('userMenuTrigger');
1514  var menu = document.getElementById('userMenu');
1515  if (!trigger || !menu) return;
1516
1517  // Build menu HTML from config
1518  var headerHTML = trigger.querySelector('.yr-sf-info');
1519  var name = headerHTML ? headerHTML.querySelector('.yr-sf-name').textContent : '';
1520  var school = headerHTML ? headerHTML.querySelector('.yr-sf-role').textContent : '';
1521  var initials = trigger.querySelector('.yr-sf-avatar') ? trigger.querySelector('.yr-sf-avatar').textContent.trim() : '';
1522
1523  var html = '<div class="yr-user-menu-header" role="presentation">' +
1524    '<div class="yr-um-avatar" aria-hidden="true">' + initials + '</div>' +
1525    '<div><div class="yr-um-name">' + name + '</div>' +
1526    '<div class="yr-um-school">' + school + '</div></div></div>';
1527
1528  items.forEach(function (item) {
1529    if (item === 'separator') {
1530      html += '<div class="yr-um-sep" role="separator"></div>';
1531      return;
1532    }
1533    var cls = item.cls ? ' class="' + item.cls + '"' : '';
1534    var id = item.id ? ' id="' + item.id + '"' : '';
1535    var href = item.href || '#';
1536    html += '<a href="' + href + '" role="menuitem"' + cls + id + '>' +
1537      item.icon + item.label + '</a>';
1538  });
1539
1540  menu.innerHTML = html;
1541
1542  // Open/close functions
1543  function getMenuItems() { return menu.querySelectorAll('a[role="menuitem"]'); }
1544
1545  function openMenu() {
1546    menu.classList.add('open');
1547    trigger.classList.add('open');
1548    trigger.setAttribute('aria-expanded', 'true');
1549    var first = getMenuItems()[0];
1550    if (first) first.focus();
1551  }
1552
1553  function closeMenu(returnFocus) {
1554    menu.classList.remove('open');
1555    trigger.classList.remove('open');
1556    trigger.setAttribute('aria-expanded', 'false');
1557    if (returnFocus !== false) trigger.focus();
1558  }
1559
1560  // Click to toggle
1561  trigger.addEventListener('click', function (e) {
1562    e.stopPropagation();
1563    menu.classList.contains('open') ? closeMenu() : openMenu();
1564  });
1565
1566  // Keyboard on trigger
1567  trigger.addEventListener('keydown', function (e) {
1568    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); menu.classList.contains('open') ? closeMenu() : openMenu(); }
1569    if (e.key === 'ArrowUp') { e.preventDefault(); openMenu(); var items = getMenuItems(); items[items.length - 1].focus(); }
1570    if (e.key === 'ArrowDown') { e.preventDefault(); openMenu(); }
1571  });
1572
1573  // Keyboard inside menu
1574  menu.addEventListener('keydown', function (e) {
1575    var items = Array.from(getMenuItems());
1576    var idx = items.indexOf(document.activeElement);
1577    if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1) % items.length].focus(); }
1578    if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length].focus(); }
1579    if (e.key === 'Escape') { e.preventDefault(); closeMenu(); }
1580    if (e.key === 'Tab') closeMenu(false);
1581    if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
1582    if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
1583  });
1584
1585  // Close on item click
1586  getMenuItems().forEach(function (link) {
1587    link.addEventListener('click', function (e) { e.stopPropagation(); closeMenu(); });
1588  });
1589
1590  // Close on outside click
1591  document.addEventListener('click', function (e) {
1592    if (!trigger.contains(e.target)) closeMenu(false);
1593  });
1594
1595  // ESC handler (integrates with sidebar)
1596  document.addEventListener('keydown', function (e) {
1597    if (e.key === 'Escape' && menu.classList.contains('open')) {
1598      closeMenu();
1599    }
1600  });
1601
1602  YR._closeUserMenu = closeMenu;
1603  YR._openUserMenu = openMenu;
1604
1605  return { open: openMenu, close: closeMenu };
1606};
1607
1608
1609// ═══════════════════════════════════════════════════════════════
1610// §16 — INIT ACCESSIBILITY
1611// Generates modal, wires toggles, persists to cookie, restores.
1612// Call AFTER initUserMenu() so the menu item exists.
1613// ═══════════════════════════════════════════════════════════════
1614
1615YR.A11Y_TOGGLES = [
1616  { id: 'a11yLargeText',     cls: 'yr-large-text',     label: 'Larger Text',       desc: 'Increases font sizes across the interface for easier reading.' },
1617  { id: 'a11yHighContrast',  cls: 'yr-high-contrast',  label: 'High Contrast',     desc: 'Stronger colors and thicker borders for improved visibility.' },
1618  { id: 'a11yReducedMotion', cls: 'yr-reduced-motion', label: 'Reduced Motion',    desc: 'Disables animations and transitions throughout the interface.' },
1619  { id: 'a11ySpacing',       cls: 'yr-extra-spacing',  label: 'Increased Spacing', desc: 'More space between lines, letters, and interface elements.' }
1620];
1621
1622YR.initAccessibility = function () {
1623  // Build modal HTML
1624  var optionsHTML = '';
1625  YR.A11Y_TOGGLES.forEach(function (t) {
1626    optionsHTML += '<div class="yr-a11y-option">' +
1627      '<div class="yr-a11y-opt-info">' +
1628        '<div class="yr-a11y-opt-label">' + t.label + '</div>' +
1629        '<div class="yr-a11y-opt-desc">' + t.desc + '</div>' +
1630      '</div>' +
1631      '<label class="yr-toggle">' +
1632        '<input type="checkbox" id="' + t.id + '" aria-label="Enable ' + t.label.toLowerCase() + '">' +
1633        '<span class="yr-toggle-track"></span>' +
1634      '</label></div>';
1635  });
1636
1637  var modalHTML =
1638    '<div class="yr-a11y-modal">' +
1639      '<div class="yr-a11y-header">' +
1640        '<h2>' +
1641          '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="7" r="1.5" fill="currentColor" stroke="none"/><path d="M9 11h6"/><path d="M10 11v5l-1 2"/><path d="M14 11v5l1 2"/></svg>' +
1642          ' Accessibility' +
1643        '</h2>' +
1644        '<button class="yr-a11y-close" id="a11yClose" aria-label="Close accessibility settings">' +
1645          '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
1646        '</button>' +
1647      '</div>' +
1648      '<div class="yr-a11y-body">' +
1649        '<p class="yr-a11y-desc">Adjust how Typing Agent looks and behaves. Changes take effect immediately.</p>' +
1650        optionsHTML +
1651      '</div>' +
1652    '</div>';
1653
1654  // Create overlay
1655  var overlay = document.getElementById('a11yOverlay');
1656  if (!overlay) {
1657    overlay = document.createElement('div');
1658    overlay.className = 'yr-a11y-overlay';
1659    overlay.id = 'a11yOverlay';
1660    overlay.setAttribute('role', 'dialog');
1661    overlay.setAttribute('aria-modal', 'true');
1662    overlay.setAttribute('aria-label', 'Accessibility settings');
1663    document.body.appendChild(overlay);
1664  }
1665  overlay.innerHTML = modalHTML;
1666
1667  var closeBtn = document.getElementById('a11yClose');
1668
1669  function openA11y() {
1670    if (YR._closeUserMenu) YR._closeUserMenu(false);
1671    overlay.classList.add('open');
1672    YR.lockBody();
1673    if (closeBtn) closeBtn.focus();
1674  }
1675
1676  function closeA11y() {
1677    overlay.classList.remove('open');
1678    YR.unlockBody();
1679    var trigger = document.getElementById('userMenuTrigger');
1680    if (trigger) trigger.focus();
1681  }
1682
1683  // Wire accessibility menu item
1684  var menuItem = document.getElementById('a11yMenuItem');
1685  if (menuItem) {
1686    menuItem.addEventListener('click', function (e) {
1687      e.preventDefault();
1688      e.stopPropagation();
1689      openA11y();
1690    });
1691  }
1692
1693  // Close handlers
1694  if (closeBtn) closeBtn.addEventListener('click', closeA11y);
1695  overlay.addEventListener('click', function (e) { if (e.target === overlay) closeA11y(); });
1696  document.addEventListener('keydown', function (e) {
1697    if (e.key === 'Escape' && overlay.classList.contains('open')) closeA11y();
1698  });
1699
1700  // Save prefs to cookie
1701  function savePrefs() {
1702    var prefs = {};
1703    YR.A11Y_TOGGLES.forEach(function (t) {
1704      var el = document.getElementById(t.id);
1705      if (el) prefs[t.id] = el.checked;
1706    });
1707    YR.setCookie(YR.COOKIE_NAME, prefs);
1708  }
1709
1710  // Wire toggles
1711  YR.A11Y_TOGGLES.forEach(function (t) {
1712    var el = document.getElementById(t.id);
1713    if (!el) return;
1714    el.addEventListener('change', function () {
1715      document.body.classList.toggle(t.cls, this.checked);
1716      savePrefs();
1717    });
1718  });
1719
1720  // Restore prefs from cookie
1721  var prefs = YR.getCookie(YR.COOKIE_NAME);
1722  if (prefs) {
1723    YR.A11Y_TOGGLES.forEach(function (t) {
1724      if (prefs[t.id]) {
1725        var el = document.getElementById(t.id);
1726        if (el) {
1727          el.checked = true;
1728          document.body.classList.add(t.cls);
1729        }
1730      }
1731    });
1732  }
1733
1734  return { open: openA11y, close: closeA11y };
1735};
1736
1737
1738// ═══════════════════════════════════════════════════════════════
1739// §17 — HEAD SCRIPT (Flash Prevention)
1740// Returns a string to inject in <head> so accessibility prefs
1741// apply BEFORE the body renders. Prevents white-flash-then-dark.
1742//
1743// Usage in Yii2 layout <head>:
1744//   <?= YR.headScript() ?>
1745// Or paste the output directly.
1746// ═══════════════════════════════════════════════════════════════
1747
1748YR.headScript = function () {
1749  return '<script>' +
1750    '(function(){try{' +
1751    'var m=document.cookie.match(/yr_prefs=([^;]*)/);' +
1752    'if(!m)return;' +
1753    'var p=JSON.parse(decodeURIComponent(m[1]));' +
1754    'var map={a11yLargeText:"yr-large-text",a11yHighContrast:"yr-high-contrast",' +
1755    'a11yReducedMotion:"yr-reduced-motion",a11ySpacing:"yr-extra-spacing"};' +
1756    'for(var k in map){if(p[k])document.documentElement.classList.add(map[k])}' +
1757    '}catch(e){}})();' +
1758    '</' + 'script>';
1759};
1760
1761
1762// ═══════════════════════════════════════════════════════════════
1763// §18 — STICKY TABLE HEADER (JS-based, works everywhere)
1764// CSS position:sticky fails when ANY ancestor has overflow clipping.
1765// This approach clones the <thead> into a fixed container that
1766// appears when the real header scrolls out of view, and disappears
1767// when you scroll back up. Works in iframes, Bootstrap wrappers,
1768// overflow:hidden ancestors — everywhere.
1769//
1770// Usage:  YR.initStickyHeader('#reportTable');
1771//         (call after renderTable() so the table has content)
1772// ═══════════════════════════════════════════════════════════════
1773
1774YR.initStickyHeader = function (tableSelector) {
1775  var table = document.querySelector(tableSelector);
1776  if (!table) return null;
1777
1778  var thead = table.querySelector('thead');
1779  if (!thead) return null;
1780
1781  // Create the floating header container
1782  var floater = document.createElement('div');
1783  floater.className = 'yr-sticky-floater';
1784  floater.setAttribute('aria-hidden', 'true');
1785  floater.style.display = 'none';
1786
1787  // Re-entrancy guard for the clone→real proxies below. A proxied interaction
1788  // can make the host page re-render, which calls refresh() and rebuilds the
1789  // clone; without this flag a handler firing on the rebuilt clone could
1790  // bounce back into the real control and loop.
1791  var proxying = false;
1792
1793  /**
1794   * Forward one cloned form control's interactions to the real control.
1795   * cloneNode(true) copies markup but NOT event listeners, so the clone's own
1796   * control is inert — the host page's listener lives on the real element.
1797   * @param {HTMLElement} copyCtrl the control inside the cloned header.
1798   * @param {HTMLElement} realCtrl the matching control in the real header.
1799   * @return {void}
1800   */
1801  function wireStickyControl(copyCtrl, realCtrl) {
1802    var type = (copyCtrl.type || '').toLowerCase();
1803
1804    if (type === 'checkbox' || type === 'radio') {
1805      // The roster pages (students/staff/classes) listen for 'change' on the
1806      // real #headerCheck, so set the REAL control's state and dispatch there.
1807      copyCtrl.addEventListener('change', function () {
1808        if (proxying) return;
1809        proxying = true;
1810        try {
1811          realCtrl.checked = copyCtrl.checked;
1812          realCtrl.indeterminate = false;
1813          realCtrl.dispatchEvent(new Event('change', { bubbles: true }));
1814        } finally { proxying = false; }
1815        syncStickyState();
1816      });
1817      return;
1818    }
1819
1820    if (copyCtrl.tagName === 'BUTTON' || type === 'button' || type === 'submit') {
1821      copyCtrl.addEventListener('click', function (e) {
1822        e.stopPropagation();              // the <th> proxy must not also fire
1823        if (proxying) return;
1824        proxying = true;
1825        try { realCtrl.click(); } finally { proxying = false; }
1826        syncStickyState();
1827      });
1828      return;
1829    }
1830
1831    // Text inputs / selects: mirror the value onto the real control and replay
1832    // both events hosts commonly bind.
1833    ['input', 'change'].forEach(function (evt) {
1834      copyCtrl.addEventListener(evt, function (e) {
1835        e.stopPropagation();
1836        if (proxying) return;
1837        proxying = true;
1838        try {
1839          realCtrl.value = copyCtrl.value;
1840          realCtrl.dispatchEvent(new Event(evt, { bubbles: true }));
1841        } finally { proxying = false; }
1842        syncStickyState();
1843      });
1844    });
1845  }
1846
1847  /**
1848   * Build the floater's proxying copy of the real <thead>.
1849   * Used by BOTH the initial mount and refresh() so the two can't drift.
1850   * Strips every id in the subtree (a cloned #headerCheck would make the host's
1851   * document.getElementById() DOM-order dependent), then wires each cloned cell
1852   * to forward clicks to its real <th> and each cloned control to its twin.
1853   * @param {HTMLTableSectionElement} realHead the live <thead> to copy.
1854   * @return {HTMLTableSectionElement} the detached, proxied clone.
1855   */
1856  function buildStickyHead(realHead) {
1857    var copy = realHead.cloneNode(true);
1858
1859    // The clone lives on <body> alongside the real table — ids must not double
1860    // up. Nothing in the floater is addressed by id (all wiring below is by
1861    // element reference), so stripping is safe. `for=` would otherwise re-point
1862    // the clone's labels at the REAL controls.
1863    if (copy.id) copy.removeAttribute('id');
1864    copy.querySelectorAll('[id]').forEach(function (node) { node.removeAttribute('id'); });
1865    copy.querySelectorAll('label[for]').forEach(function (node) { node.removeAttribute('for'); });
1866
1867    var realCells = realHead.querySelectorAll('th');
1868    var copyCells = copy.querySelectorAll('th');
1869
1870    for (var i = 0; i < copyCells.length && i < realCells.length; i++) {
1871      (function (copyTh, realTh) {
1872        var copyCtrls = copyTh.querySelectorAll('input, select, textarea, button');
1873        var realCtrls = realTh.querySelectorAll('input, select, textarea, button');
1874        for (var j = 0; j < copyCtrls.length && j < realCtrls.length; j++) {
1875          wireStickyControl(copyCtrls[j], realCtrls[j]);
1876        }
1877
1878        // Forward the cell click to the real <th> UNCONDITIONALLY — not only
1879        // when origTh.onclick happens to exist. Every roster binds its sort
1880        // with addEventListener('click') on `#rosterTable thead th.sortable`,
1881        // which cloneNode never copies; clicking the real <th> runs whatever
1882        // the page bound (addEventListener or onclick). A <th> with no
1883        // listener is a harmless no-op.
1884        copyTh.addEventListener('click', function (e) {
1885          if (e.target.closest('input, select, textarea, button, label, a')) return;
1886          if (proxying) return;
1887          proxying = true;
1888          try { realTh.click(); } finally { proxying = false; }
1889          syncStickyState();
1890        });
1891      })(copyCells[i], realCells[i]);
1892    }
1893
1894    return copy;
1895  }
1896
1897  /**
1898   * Mirror the real header's live state onto the clone — sort markers
1899   * (class + aria-sort) and control state (checked/indeterminate/value).
1900   * cloneNode copies the `checked` ATTRIBUTE, not the live property, so this
1901   * also seeds a freshly built clone correctly.
1902   * @return {void}
1903   */
1904  function syncStickyState() {
1905    var realCells = thead.querySelectorAll('th');
1906    var copyCells = clonedHead.querySelectorAll('th');
1907    for (var i = 0; i < copyCells.length && i < realCells.length; i++) {
1908      var realTh = realCells[i];
1909      var copyTh = copyCells[i];
1910
1911      copyTh.className = realTh.className;
1912      var sortState = realTh.getAttribute('aria-sort');
1913      if (sortState) copyTh.setAttribute('aria-sort', sortState);
1914      else copyTh.removeAttribute('aria-sort');
1915
1916      var realCtrls = realTh.querySelectorAll('input, select, textarea');
1917      var copyCtrls = copyTh.querySelectorAll('input, select, textarea');
1918      for (var j = 0; j < copyCtrls.length && j < realCtrls.length; j++) {
1919        var t = (copyCtrls[j].type || '').toLowerCase();
1920        if (t === 'checkbox' || t === 'radio') {
1921          copyCtrls[j].checked = realCtrls[j].checked;
1922          copyCtrls[j].indeterminate = realCtrls[j].indeterminate;
1923        } else {
1924          copyCtrls[j].value = realCtrls[j].value;
1925        }
1926      }
1927    }
1928  }
1929
1930  // Clone the table structure with only the thead
1931  var clone = document.createElement('table');
1932  clone.className = table.className;
1933  var clonedHead = buildStickyHead(thead);
1934  clone.appendChild(clonedHead);
1935  floater.appendChild(clone);
1936  document.body.appendChild(floater);
1937
1938  // Sync column widths from real table to cloned header
1939  function syncWidths() {
1940    var realCells = thead.querySelectorAll('th');
1941    var cloneCells = clonedHead.querySelectorAll('th');
1942    var tableRect = table.getBoundingClientRect();
1943
1944    // The real header may have changed state (sorted, Select All toggled) while
1945    // the floater was hidden — re-mirror it on every show/resize.
1946    syncStickyState();
1947
1948    floater.style.left = tableRect.left + 'px';
1949    floater.style.width = tableRect.width + 'px';
1950
1951    for (var i = 0; i < realCells.length && i < cloneCells.length; i++) {
1952      // Mirror the real header's per-column visibility onto the clone. Pages
1953      // hide columns via scoped CSS (e.g. the Columns picker toggles
1954      // .stu-cols-hidden-* on #rosterTable), but those rules are written as
1955      // `.yr-scope #rosterTable…` and the floater is a bare clone on <body>
1956      // with no id and no .yr-scope ancestor — so they never reach it and the
1957      // sticky header would show columns the table is hiding. Copy the computed
1958      // display per-cell instead, which works regardless of how the host page
1959      // hides the column (class, nth-child, or data-col selector).
1960      var realHidden = window.getComputedStyle(realCells[i]).display === 'none';
1961      cloneCells[i].style.display = realHidden ? 'none' : '';
1962      if (realHidden) continue;
1963      cloneCells[i].style.width = realCells[i].getBoundingClientRect().width + 'px';
1964    }
1965  }
1966
1967  // Show/hide based on scroll position
1968  var ticking = false;
1969  function onScroll() {
1970    if (ticking) return;
1971    ticking = true;
1972    requestAnimationFrame(function () {
1973      ticking = false;
1974      var theadRect = thead.getBoundingClientRect();
1975      var tableRect = table.getBoundingClientRect();
1976
1977      // Show when real thead is above viewport AND table bottom is still visible
1978      var shouldShow = theadRect.bottom < 0 && tableRect.bottom > 60;
1979
1980      if (shouldShow) {
1981        if (floater.style.display === 'none') {
1982          syncWidths();
1983          floater.style.display = 'block';
1984        }
1985      } else {
1986        floater.style.display = 'none';
1987      }
1988    });
1989  }
1990
1991  window.addEventListener('scroll', onScroll, { passive: true });
1992  function onResize() {
1993    if (floater.style.display !== 'none') syncWidths();
1994  }
1995  window.addEventListener('resize', onResize);
1996
1997  // Re-sync if table is re-rendered (e.g. after sort)
1998  var refresh = function () {
1999    // Re-clone the thead content
2000    var newClonedHead = buildStickyHead(thead);
2001    clone.replaceChild(newClonedHead, clone.querySelector('thead'));
2002    clonedHead = newClonedHead;
2003    syncStickyState();
2004    if (floater.style.display !== 'none') syncWidths();
2005  };
2006  YR._stickyRefresh = refresh;
2007
2008  // Teardown handle. The floater lives on <body> and the scroll/resize
2009  // listeners live on window — all OUTSIDE #main, so an AjaxNav innerHTML
2010  // swap can't remove them. Pages MUST call destroy() from an
2011  // AjaxNav.registerPageCleanup, or this header rides onto the next page:
2012  // a fixed, pointer-events:auto floater left over the next page swallows its
2013  // clicks, and the orphaned scroll handlers keep firing against a detached
2014  // table on every page visited afterward.
2015  var destroyed = false;
2016  function destroy() {
2017    if (destroyed) return;          // idempotent — callers may also call this
2018    destroyed = true;
2019    window.removeEventListener('scroll', onScroll, { passive: true });
2020    window.removeEventListener('resize', onResize);
2021    if (floater.parentNode) floater.parentNode.removeChild(floater);
2022    // Only clear the shared refresh hook if it's still ours — a sticky header
2023    // initialized on the next page may have already replaced it.
2024    if (YR._stickyRefresh === refresh) YR._stickyRefresh = null;
2025  }
2026
2027  // Self-register teardown with the AjaxNav shell so EVERY caller is cleaned up
2028  // on nav-away without per-page wiring. registerPageCleanup runs each callback
2029  // once, right before #main is replaced, then clears its registry — and this
2030  // header was created during the current page's init, so the registration
2031  // lands in the current page's (fresh) registry and fires when you leave.
2032  // Guarded for non-AjaxNav hosts (e.g. the teacher-module reports, which do
2033  // full reloads and so never leak across an SPA swap). destroy() is idempotent,
2034  // so a caller that ALSO tears down explicitly (to rebuild in-place) is safe.
2035  if (window.AjaxNav && typeof window.AjaxNav.registerPageCleanup === 'function') {
2036    window.AjaxNav.registerPageCleanup(destroy);
2037  }
2038
2039  return { refresh: refresh, destroy: destroy };
2040};
2041
2042
2043// ═══════════════════════════════════════════════════════════════
2044// §19 — TABLE PAGINATION
2045// Client-side pager that composes with YR.sortTable + filtering.
2046// Replaces state.renderFn so the host's row renderer receives only
2047// the active page slice. Sort changes reset to page 1 automatically
2048// (see hook in YR.sortTable). Filter changes should also reset —
2049// call pager.reset() after rebuilding state.data.
2050//
2051// Usage:
2052//   const pager = YR.initPagination({
2053//     state:        sortState,            // from YR.sortTable convention
2054//     pageSize:     50,                   // rows per page
2055//     mount:        '#pagerMount',        // selector or element
2056//     renderRows:   renderTbody,          // function(rows) → write tbody
2057//     pageSizeOptions: [25, 50, 100],     // optional dropdown values
2058//     label:        'teachers'            // optional, used in info text
2059//   });
2060//
2061//   // After filter rebuild:
2062//   sortState.data = filtered();
2063//   YR.applySortToData(sortState);
2064//   pager.reset();
2065//
2066// Returns: { reset, refresh, goTo, setPageSize, getPage }
2067// ═══════════════════════════════════════════════════════════════
2068YR.initPagination = function (cfg) {
2069  cfg = cfg || {};
2070  var state = cfg.state;
2071  if (!state) { console.warn('YR.initPagination: state is required'); return null; }
2072  if (!cfg.renderRows) { console.warn('YR.initPagination: renderRows is required'); return null; }
2073
2074  var mount = (typeof cfg.mount === 'string') ? document.querySelector(cfg.mount) : cfg.mount;
2075  if (!mount) { console.warn('YR.initPagination: mount not found', cfg.mount); return null; }
2076
2077  var renderRows      = cfg.renderRows;
2078  var pageSizeOpts    = cfg.pageSizeOptions || null;  // null = no selector shown
2079  var label           = cfg.label || 'rows';
2080
2081  state.page          = 1;
2082  state.pageSize      = cfg.pageSize || 50;
2083
2084  // ── Compute which page numbers to show (windowed with ellipses) ─
2085  // For ≤7 pages → all numbers
2086  // For >7      → 1, …, current-1, current, current+1, …, last
2087  function pageWindow(current, total) {
2088    if (total <= 7) {
2089      var out = [];
2090      for (var i = 1; i <= total; i++) out.push(i);
2091      return out;
2092    }
2093    var w = [1];
2094    if (current > 3) w.push('…');
2095    var startN = Math.max(2, current - 1);
2096    var endN   = Math.min(total - 1, current + 1);
2097    for (var n = startN; n <= endN; n++) w.push(n);
2098    if (current < total - 2) w.push('…');
2099    w.push(total);
2100    return w;
2101  }
2102
2103  // ── Build pager footer markup ────────────────────────────────
2104  function buildPagerHTML(total, totalPages) {
2105    var first = total === 0 ? 0 : (state.page - 1) * state.pageSize + 1;
2106    var last  = Math.min(state.page * state.pageSize, total);
2107
2108    var sizeSelector = '';
2109    if (pageSizeOpts && pageSizeOpts.length) {
2110      sizeSelector =
2111        '<label class="yr-pg-size">' +
2112          '<span>Rows per page</span>' +
2113          '<select class="yr-pg-size-select" aria-label="Rows per page">' +
2114            pageSizeOpts.map(function (n) {
2115              return '<option value="' + n + '"' + (n === state.pageSize ? ' selected' : '') + '>' + n + '</option>';
2116            }).join('') +
2117          '</select>' +
2118        '</label>';
2119    }
2120
2121    var info = total === 0
2122      ? '<div class="yr-pager-info">No ' + label + '</div>'
2123      : '<div class="yr-pager-info">Showing <strong>' + first + '–' + last + '</strong> of <strong>' + total + '</strong> ' + label + '</div>';
2124
2125    var navButtons = '';
2126    if (totalPages > 1) {
2127      navButtons += '<button class="yr-pg-btn yr-pg-arrow" data-page="prev" aria-label="Previous page"' +
2128        (state.page === 1 ? ' disabled' : '') + '>‹</button>';
2129      pageWindow(state.page, totalPages).forEach(function (p) {
2130        if (p === '…') {
2131          navButtons += '<span class="yr-pg-ellipsis" aria-hidden="true">…</span>';
2132        } else {
2133          navButtons += '<button class="yr-pg-btn yr-pg-num' + (p === state.page ? ' active' : '') +
2134            '" data-page="' + p + '"' +
2135            (p === state.page ? ' aria-current="page"' : '') +
2136            ' aria-label="Page ' + p + '">' + p + '</button>';
2137        }
2138      });
2139      navButtons += '<button class="yr-pg-btn yr-pg-arrow" data-page="next" aria-label="Next page"' +
2140        (state.page === totalPages ? ' disabled' : '') + '>›</button>';
2141    }
2142
2143    return '<div class="yr-pager" role="navigation" aria-label="Table pagination">' +
2144      '<div class="yr-pager-left">' + info + sizeSelector + '</div>' +
2145      '<div class="yr-pager-nav">' + navButtons + '</div>' +
2146    '</div>';
2147  }
2148
2149  // ── The new state.renderFn slices, renders rows, then footer ─
2150  state.renderFn = function () {
2151    var total = state.data.length;
2152    var totalPages = Math.max(1, Math.ceil(total / state.pageSize));
2153
2154    if (state.page > totalPages) state.page = totalPages;
2155    if (state.page < 1) state.page = 1;
2156
2157    var startIdx = (state.page - 1) * state.pageSize;
2158    var endIdx   = Math.min(startIdx + state.pageSize, total);
2159    var slice    = state.data.slice(startIdx, endIdx);
2160
2161    renderRows(slice);
2162    mount.innerHTML = buildPagerHTML(total, totalPages);
2163
2164    if (YR._stickyRefresh) YR._stickyRefresh();
2165  };
2166
2167  // ── Click delegation on the pager mount ──────────────────────
2168  mount.addEventListener('click', function (e) {
2169    var btn = e.target.closest('.yr-pg-btn');
2170    if (!btn || btn.disabled) return;
2171
2172    var total = state.data.length;
2173    var totalPages = Math.max(1, Math.ceil(total / state.pageSize));
2174    var target = btn.dataset.page;
2175
2176    if (target === 'prev')      state.page = Math.max(1, state.page - 1);
2177    else if (target === 'next') state.page = Math.min(totalPages, state.page + 1);
2178    else                        state.page = parseInt(target, 10);
2179
2180    state.renderFn();
2181  });
2182
2183  // Page size selector
2184  mount.addEventListener('change', function (e) {
2185    if (!e.target.classList || !e.target.classList.contains('yr-pg-size-select')) return;
2186    state.pageSize = parseInt(e.target.value, 10);
2187    state.page = 1;
2188    state.renderFn();
2189  });
2190
2191  // Initial render
2192  state.renderFn();
2193
2194  // ── Public API ───────────────────────────────────────────────
2195  return {
2196    reset:       function () { state.page = 1; state.renderFn(); },
2197    refresh:     function () { state.renderFn(); },
2198    goTo:        function (n) { state.page = n; state.renderFn(); },
2199    setPageSize: function (n) { state.pageSize = n; state.page = 1; state.renderFn(); },
2200    getPage:     function () { return state.page; }
2201  };
2202};
2203
2204
2205// ═══════════════════════════════════════════════════════════════
2206// Make globally available
2207// ═══════════════════════════════════════════════════════════════
2208
2209
2210/* ═══════════════════════════════════════════════════════════════
2211   §18 — DROPDOWN COMPONENT (YR.initDropdown)
2212   v1.4.0 — Generic styled dropdown. Replaces native <select> with
2213   a fully styled, keyboard-navigable, ARIA-compliant popup. Use
2214   for class pickers, student pickers, filters, etc.
2215
2216   Required markup:
2217     <div class="yr-dd-wrap" id="dd-X" data-value="initial">
2218       <button class="yr-dd-button" type="button"
2219               aria-haspopup="listbox" aria-expanded="false">
2220         <span class="yr-dd-label"></span>
2221         <svg class="yr-dd-chevron" viewBox="0 0 12 12" fill="none"
2222              stroke="currentColor" stroke-width="2"
2223              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2224           <path d="M3 4.5 L6 7.5 L9 4.5"/>
2225         </svg>
2226       </button>
2227       <div class="yr-dd-menu is-hidden" role="listbox"></div>
2228     </div>
2229
2230   config: { wrapId, options: [{value, label, meta?}], onChange? }
2231   returns: { setValue, getValue, getLabel, setDisabled }
2232   ═══════════════════════════════════════════════════════════════ */
2233
2234/**
2235 * Bind the document-level "close on outside click / Escape" behavior ONCE for
2236 * the whole page, operating on whatever dropdown/export menus are currently
2237 * open in the live DOM. Previously each initDropdown / initExportMenu call
2238 * added its own document listeners closing over that call's wrap node; under
2239 * AjaxNav (which re-runs init() on every visit against fresh nodes) those
2240 * listeners accumulated, each pinned to a now-detached node — a growing leak
2241 * and a source of cross-instance interference. The guard lives on `window`
2242 * (not YR) so it survives a kit reload / redefinition.
2243 */
2244function ensureGlobalMenuHandlers() {
2245  if (window.__yrMenuGlobalBound) return;
2246  window.__yrMenuGlobalBound = true;
2247
2248  document.addEventListener('click', function (e) {
2249    var openMenus = document.querySelectorAll(
2250      '.yr-dd-wrap .yr-dd-menu:not(.is-hidden), .yr-export-wrap .yr-export-menu:not(.is-hidden)'
2251    );
2252    for (var i = 0; i < openMenus.length; i++) {
2253      var menu = openMenus[i];
2254      var wrap = menu.closest('.yr-dd-wrap, .yr-export-wrap');
2255      if (!wrap || wrap.contains(e.target)) continue;
2256      menu.classList.add('is-hidden');
2257      var btn = wrap.querySelector('.yr-dd-button, .yr-export-btn');
2258      if (btn) btn.setAttribute('aria-expanded', 'false');
2259    }
2260  });
2261
2262  document.addEventListener('keydown', function (e) {
2263    if (e.key !== 'Escape') return;
2264    var openMenus = document.querySelectorAll(
2265      '.yr-dd-menu:not(.is-hidden), .yr-export-menu:not(.is-hidden)'
2266    );
2267    for (var i = 0; i < openMenus.length; i++) {
2268      openMenus[i].classList.add('is-hidden');
2269      var wrap = openMenus[i].closest('.yr-dd-wrap, .yr-export-wrap');
2270      var btn = wrap && wrap.querySelector('.yr-dd-button, .yr-export-btn');
2271      if (btn) btn.setAttribute('aria-expanded', 'false');
2272    }
2273  });
2274}
2275
2276YR.initDropdown = function (config) {
2277  var wrap = document.getElementById(config.wrapId);
2278  if (!wrap) {
2279    console.warn('YR.initDropdown: element not found:', config.wrapId);
2280    return null;
2281  }
2282
2283  // Idempotent binding. Under AjaxNav, init() can run more than once against
2284  // the same wrap (a re-init on a node the router kept alive, or a double boot
2285  // from both the inline boot script and the IIFE bootstrap). Binding again
2286  // would add a SECOND click listener to the button — one opening the menu,
2287  // the other immediately closing it — so the button appears dead. To stay
2288  // safe regardless of whether the node is fresh or reused, clone-and-replace
2289  // the button and menu first: cloning drops any listeners a previous init
2290  // left on them, and we then bind exactly once below.
2291  var btn = wrap.querySelector('.yr-dd-button');
2292  var menu = wrap.querySelector('.yr-dd-menu');
2293  if (btn && btn.parentNode) {
2294    var freshBtn = btn.cloneNode(true);
2295    btn.parentNode.replaceChild(freshBtn, btn);
2296    btn = freshBtn;
2297  }
2298  if (menu && menu.parentNode) {
2299    var freshMenu = menu.cloneNode(false); // empty; renderOptions fills it
2300    menu.parentNode.replaceChild(freshMenu, menu);
2301    menu = freshMenu;
2302  }
2303  var labelEl = btn ? btn.querySelector('.yr-dd-label') : wrap.querySelector('.yr-dd-label');
2304  var value = wrap.dataset.value || (config.options[0] && config.options[0].value);
2305
2306  function escapeHtml(s) {
2307    return String(s).replace(/[&<>"']/g, function (c) {
2308      return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
2309    });
2310  }
2311
2312  // data-* values always come back from the DOM as strings. Keep the public
2313  // value unchanged, but compare at that DOM boundary so numeric option IDs
2314  // still resolve to their labels and selected state.
2315  function valuesMatch(a, b) {
2316    return String(a) === String(b);
2317  }
2318
2319  // Render the option list and (re)bind per-option click handlers. Safe to
2320  // call repeatedly — innerHTML replaces the old option nodes (and their
2321  // listeners) so there is no double-binding, unlike re-running initDropdown.
2322  function renderOptions() {
2323    menu.innerHTML = config.options.map(function (o) {
2324      return '<button type="button" role="option" class="yr-dd-option" ' +
2325             'data-value="' + escapeHtml(o.value) + '" tabindex="-1">' +
2326             escapeHtml(o.label) +
2327             (o.meta ? '<span class="yr-dd-meta">' + escapeHtml(o.meta) + '</span>' : '') +
2328             '</button>';
2329    }).join('');
2330
2331    var optionNodes = menu.querySelectorAll('.yr-dd-option');
2332    for (var i = 0; i < optionNodes.length; i++) {
2333      optionNodes[i].addEventListener('click', function (e) {
2334        e.stopPropagation();
2335        setValue(this.dataset.value);
2336        close();
2337        btn.focus();
2338      });
2339    }
2340  }
2341
2342  function open() {
2343    if (wrap.classList.contains('is-disabled')) return;
2344    menu.classList.remove('is-hidden');
2345    btn.setAttribute('aria-expanded', 'true');
2346    var sel = menu.querySelector('[aria-selected="true"]') ||
2347              menu.querySelector('.yr-dd-option');
2348    if (sel) sel.focus();
2349  }
2350  function close() {
2351    menu.classList.add('is-hidden');
2352    btn.setAttribute('aria-expanded', 'false');
2353  }
2354  function setValue(v, opts) {
2355    opts = opts || {};
2356    value = v;
2357    wrap.dataset.value = v;
2358    var opt = config.options.filter(function (o) { return valuesMatch(o.value, v); })[0];
2359    labelEl.textContent = opt ? opt.label : '';
2360    var nodes = menu.querySelectorAll('.yr-dd-option');
2361    for (var i = 0; i < nodes.length; i++) {
2362      nodes[i].setAttribute('aria-selected',
2363        valuesMatch(nodes[i].dataset.value, v) ? 'true' : 'false');
2364    }
2365    if (!opts.silent && config.onChange) config.onChange(v);
2366  }
2367
2368  btn.addEventListener('click', function (e) {
2369    e.stopPropagation();
2370    if (menu.classList.contains('is-hidden')) open(); else close();
2371  });
2372
2373  renderOptions();
2374
2375  menu.addEventListener('keydown', function (e) {
2376    var opts = Array.prototype.slice.call(menu.querySelectorAll('.yr-dd-option'));
2377    var idx = opts.indexOf(document.activeElement);
2378    if (e.key === 'ArrowDown') {
2379      e.preventDefault(); opts[Math.min(idx + 1, opts.length - 1)].focus();
2380    } else if (e.key === 'ArrowUp') {
2381      e.preventDefault(); opts[Math.max(idx - 1, 0)].focus();
2382    } else if (e.key === 'Home') {
2383      e.preventDefault(); opts[0].focus();
2384    } else if (e.key === 'End') {
2385      e.preventDefault(); opts[opts.length - 1].focus();
2386    } else if (e.key === 'Enter' || e.key === ' ') {
2387      e.preventDefault();
2388      if (document.activeElement.classList.contains('yr-dd-option')) {
2389        setValue(document.activeElement.dataset.value);
2390        close();
2391        btn.focus();
2392      }
2393    }
2394  });
2395
2396  // Outside-click / Escape closing is handled by one shared global listener
2397  // (bound once) instead of per-instance listeners that would leak under
2398  // AjaxNav re-inits.
2399  ensureGlobalMenuHandlers();
2400
2401  setValue(value, { silent: true });
2402
2403  var api = {
2404    setValue: setValue,
2405    getValue: function () { return value; },
2406    getLabel: function () {
2407      var opt = config.options.filter(function (o) { return valuesMatch(o.value, value); })[0];
2408      return opt ? opt.label : '';
2409    },
2410    setDisabled: function (d) {
2411      wrap.classList.toggle('is-disabled', d);
2412      if (d) close();
2413    },
2414    // Swap the option list in place (e.g. after the underlying data reloads).
2415    // opts.value sets the new selection (defaults to the first option);
2416    // selection is applied silently (no onChange).
2417    setOptions: function (newOptions, opts) {
2418      opts = opts || {};
2419      config.options = newOptions || [];
2420      renderOptions();
2421      var v = opts.value != null
2422        ? opts.value
2423        : (config.options[0] && config.options[0].value);
2424      setValue(v, { silent: true });
2425    }
2426  };
2427
2428  return api;
2429};
2430
2431
2432/* ═══════════════════════════════════════════════════════════════
2433   §19 — SKELETON HELPERS
2434   v1.4.0 — Convenience renderers for the two skeleton patterns
2435   most reports need: stat cards and table rows. For graphs and
2436   custom layouts, compose the .yr-skel* CSS primitives directly.
2437   ═══════════════════════════════════════════════════════════════ */
2438YR.skeletonStatCard = function (tone) {
2439  function escapeAttr(s) {
2440    return String(s).replace(/[&<>"']/g, function (c) {
2441      return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
2442    });
2443  }
2444  return '<div class="yr-stat-card yr-card-loading"' +
2445    (tone ? ' data-tone="' + escapeAttr(tone) + '"' : '') + '>' +
2446    '<div class="yr-loading-bar"></div>' +
2447    '<span class="yr-skel" style="width:55%; margin-bottom:10px;"></span>' +
2448    '<span class="yr-skel yr-skel-tall" style="width:70%; margin-bottom:8px;"></span>' +
2449    '<span class="yr-skel" style="width:65%;"></span>' +
2450  '</div>';
2451};
2452
2453YR.skeletonTableRow = function (colCount, opts) {
2454  opts = opts || {};
2455  var firstColIsStudent = opts.firstColIsStudent !== false;
2456  var widths = opts.cellWidths || [];
2457  var cells = '';
2458  for (var i = 0; i < colCount; i++) {
2459    var w = widths[i] || (i === 0 ? 'auto' : '60%');
2460    if (i === 0 && firstColIsStudent) {
2461      cells +=
2462        '<td>' +
2463          '<div class="yr-skel-cell">' +
2464            '<span class="yr-skel yr-skel-circle"></span>' +
2465            '<span style="display:inline-block;">' +
2466              '<span class="yr-skel" style="width:120px; margin-bottom:5px;"></span>' +
2467              '<span class="yr-skel" style="width:60px; height:10px;"></span>' +
2468            '</span>' +
2469          '</div>' +
2470        '</td>';
2471    } else {
2472      cells += '<td><span class="yr-skel" style="width:' + w + '; margin-left:auto;"></span></td>';
2473    }
2474  }
2475  return '<tr class="yr-skel-row">' + cells + '</tr>';
2476};
2477
2478
2479/* ═══════════════════════════════════════════════════════════════
2480   §20 — EXPORT MENU (YR.initExportMenu)
2481   v1.4.0 — Standard Export ▼ button + grouped popup. The kit
2482   owns UI, behavior, dispatch, and filename convention. Reports
2483   provide the option list with data builders.
2484
2485   Required markup:
2486     <div class="yr-export-wrap" id="export-X">
2487       <button class="yr-btn yr-btn-ghost yr-export-btn" type="button"
2488               aria-haspopup="true" aria-expanded="false">
2489         <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
2490              stroke="currentColor" stroke-width="2" stroke-linecap="round"
2491              stroke-linejoin="round" aria-hidden="true">
2492           <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
2493           <polyline points="7 10 12 15 17 10"/>
2494           <line x1="12" y1="15" x2="12" y2="3"/>
2495         </svg>
2496         Export
2497         <svg class="yr-caret" viewBox="0 0 12 12" fill="none"
2498              stroke="currentColor" stroke-width="2" stroke-linecap="round"
2499              stroke-linejoin="round" aria-hidden="true">
2500           <path d="M3 4.5 L6 7.5 L9 4.5"/>
2501         </svg>
2502       </button>
2503       <div class="yr-export-menu is-hidden" role="menu"></div>
2504     </div>
2505
2506   config: { wrapId, filenamePrefix, contextFn?, options: [...] }
2507   Each option: { format: 'xlsx'|'csv'|'print', scope, label,
2508                  description, buildSheet?, buildSheets? }
2509   returns: { open, close, refresh }
2510   ═══════════════════════════════════════════════════════════════ */
2511YR.initExportMenu = function (config) {
2512  var wrap = document.getElementById(config.wrapId);
2513  if (!wrap) {
2514    console.warn('YR.initExportMenu: element not found:', config.wrapId);
2515    return null;
2516  }
2517
2518  // Idempotent binding — see YR.initDropdown. Clone-and-replace the button and
2519  // menu so a previous init()'s listeners are dropped, then bind exactly once.
2520  var btn = wrap.querySelector('.yr-export-btn');
2521  var menu = wrap.querySelector('.yr-export-menu');
2522  if (btn && btn.parentNode) {
2523    var freshBtn = btn.cloneNode(true);
2524    btn.parentNode.replaceChild(freshBtn, btn);
2525    btn = freshBtn;
2526  }
2527  if (menu && menu.parentNode) {
2528    var freshMenu = menu.cloneNode(false); // empty; buildMenu fills it
2529    menu.parentNode.replaceChild(freshMenu, menu);
2530    menu = freshMenu;
2531  }
2532
2533  function escapeHtml(s) {
2534    return String(s).replace(/[&<>"']/g, function (c) {
2535      return { '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c];
2536    });
2537  }
2538
2539  function buildMenu() {
2540    var sections = { xlsx: [], csv: [], print: [] };
2541    config.options.forEach(function (o) { sections[o.format].push(o); });
2542
2543    var html = '';
2544    if (sections.xlsx.length) {
2545      html += '<div class="yr-export-menu-section">Excel (.xlsx)</div>';
2546      sections.xlsx.forEach(function (o) { html += renderOption(o, 'XLS'); });
2547    }
2548    if (sections.csv.length) {
2549      if (html) html += '<div class="yr-menu-divider"></div>';
2550      html += '<div class="yr-export-menu-section">CSV</div>';
2551      sections.csv.forEach(function (o) { html += renderOption(o, 'CSV'); });
2552    }
2553    if (sections.print.length) {
2554      if (html) html += '<div class="yr-menu-divider"></div>';
2555      html += '<div class="yr-export-menu-section">Print</div>';
2556      sections.print.forEach(function (o) { html += renderOption(o, 'PDF'); });
2557    }
2558    menu.innerHTML = html;
2559  }
2560
2561  function renderOption(o, iconLabel) {
2562    return '<button type="button" role="menuitem" ' +
2563      'data-format="' + escapeHtml(o.format) + '" ' +
2564      'data-scope="' + escapeHtml(o.scope) + '">' +
2565      '<span class="yr-opt-icon">' + iconLabel + '</span>' +
2566      '<span class="yr-opt-text">' +
2567        '<span class="yr-opt-name">' + escapeHtml(o.label) + '</span>' +
2568        (o.description ? '<span class="yr-opt-meta">' + escapeHtml(o.description) + '</span>' : '') +
2569      '</span>' +
2570    '</button>';
2571  }
2572
2573  function open()  { menu.classList.remove('is-hidden'); btn.setAttribute('aria-expanded', 'true'); }
2574  function close() { menu.classList.add('is-hidden');    btn.setAttribute('aria-expanded', 'false'); }
2575
2576  function buildFilename(ext) {
2577    var parts = [config.filenamePrefix];
2578    if (typeof config.contextFn === 'function') {
2579      var ctx = config.contextFn();
2580      if (ctx) parts.push(String(ctx).replace(/\W+/g, '-').toLowerCase().replace(/^-|-$/g, ''));
2581    }
2582    parts.push(new Date().toISOString().slice(0, 10));
2583    return parts.filter(Boolean).join('_') + '.' + ext;
2584  }
2585
2586  function showExportError(err) {
2587    if (!wrap.isConnected) return;
2588    console.error('YR.initExportMenu: export failed', err);
2589    if (typeof YR.toast === 'function') {
2590      YR.toast('error', 'Could not prepare export. Try again.');
2591      setTimeout(function () { YR.toast(); }, 2600);
2592    }
2593  }
2594
2595  function finishAsync(result, complete) {
2596    if (!result || typeof result.then !== 'function') {
2597      complete(result);
2598      return null;
2599    }
2600    btn.setAttribute('aria-busy', 'true');
2601    if (typeof YR.toast === 'function') YR.toast('loading', 'Preparing export…');
2602    return result.then(function (value) {
2603      // AjaxNav may replace the page while an async sheet builder is fetching.
2604      // A departed export menu must not download or toast into the next page.
2605      if (!wrap.isConnected) return;
2606      complete(value);
2607      if (typeof YR.toast === 'function') {
2608        YR.toast('done', 'Export ready');
2609        setTimeout(function () { YR.toast(); }, 1500);
2610      }
2611    }).catch(showExportError).then(function () {
2612      btn.removeAttribute('aria-busy');
2613    });
2614  }
2615
2616  function dispatch(opt) {
2617    if (opt.format === 'print') { window.print(); return; }
2618    if (opt.format === 'xlsx') {
2619      if (typeof YR.exportXLSX !== 'function') {
2620        console.warn('YR.initExportMenu: YR.exportXLSX not available (load SheetJS)');
2621        return;
2622      }
2623      var sheets = opt.buildSheets ? opt.buildSheets() :
2624                   opt.buildSheet  ? [opt.buildSheet()] : [];
2625      if (!opt.buildSheets && sheets[0] && typeof sheets[0].then === 'function') {
2626        sheets = sheets[0].then(function (sheet) { return [sheet]; });
2627      }
2628      return finishAsync(sheets, function (resolvedSheets) {
2629        YR.exportXLSX({ filename: buildFilename('xlsx'), sheets: resolvedSheets || [] });
2630      });
2631    }
2632    if (opt.format === 'csv') {
2633      if (typeof YR.exportCSV !== 'function') {
2634        console.warn('YR.initExportMenu: YR.exportCSV not available');
2635        return;
2636      }
2637      var sheet = opt.buildSheet ? opt.buildSheet() : null;
2638      return finishAsync(sheet, function (resolvedSheet) {
2639        if (!resolvedSheet) return;
2640        YR.exportCSV({
2641          filename: buildFilename('csv'),
2642          headers: resolvedSheet.headers,
2643          rows: resolvedSheet.rows
2644        });
2645      });
2646    }
2647  }
2648
2649  btn.addEventListener('click', function (e) {
2650    e.stopPropagation();
2651    if (menu.classList.contains('is-hidden')) open(); else close();
2652  });
2653  // Outside-click / Escape closing handled by the shared global listener.
2654  ensureGlobalMenuHandlers();
2655  menu.addEventListener('click', function (e) {
2656    var optBtn = e.target.closest('button[data-format]');
2657    if (!optBtn) return;
2658    e.stopPropagation();
2659    var format = optBtn.dataset.format;
2660    var scope = optBtn.dataset.scope;
2661    var opt = config.options.filter(function (o) {
2662      return o.format === format && o.scope === scope;
2663    })[0];
2664    close();
2665    if (opt) {
2666      try {
2667        dispatch(opt);
2668      } catch (err) {
2669        showExportError(err);
2670        btn.removeAttribute('aria-busy');
2671      }
2672    }
2673  });
2674
2675  buildMenu();
2676
2677  return { open: open, close: close, refresh: buildMenu };
2678};
2679
2680
2681/* ═══════════════════════════════════════════════════════════════
2682   §21 — REPORT VIEW TABS (YR.initReportTabs)
2683   v1.6.0 — One accessible tablist implementation for every report's
2684   Table/Graph (or equivalent) view switch. Panels are mutually
2685   exclusive; the HTML `hidden` attribute is the visibility source
2686   of truth (report-controls.css backs it with display:none).
2687
2688   Required markup (ids are per-page; relationships are required):
2689     <div class="yr-report-tabs" id="sc-view-tabs" role="tablist"
2690          aria-label="Report view">
2691       <button class="yr-report-tab" id="sc-tab-table" type="button"
2692               role="tab" data-tab="table" aria-controls="sc-table-panel"
2693               aria-selected="true" tabindex="0">Table</button>
2694       <button class="yr-report-tab" id="sc-tab-graph" type="button"
2695               role="tab" data-tab="graph" aria-controls="sc-graph-panel"
2696               aria-selected="false" tabindex="-1">Graph</button>
2697     </div>
2698     <section class="yr-report-panel" id="sc-table-panel" role="tabpanel"
2699              aria-labelledby="sc-tab-table">…</section>
2700     <section class="yr-report-panel" id="sc-graph-panel" role="tabpanel"
2701              aria-labelledby="sc-tab-graph" hidden>…</section>
2702
2703   Segmented VALUE controls (Metric, date ranges) are NOT tabs — keep
2704   them as aria-pressed button groups; this helper refuses nothing but
2705   should only wrap role="tab" markup.
2706
2707   config: {
2708     rootId:   tablist element id (required)
2709     initial:  value (data-tab) to select on init; defaults to the tab
2710               already marked aria-selected, else the first tab
2711     onChange: function (value) — fired once per GENUINE change (not
2712               during init, not on re-selecting the active tab)
2713   }
2714   returns: { getValue, setValue(value, opts), destroy }
2715     setValue opts: { silent: true } applies DOM state without onChange.
2716
2717   Idempotent under AjaxNav: re-initializing the same live root tears
2718   down the previous instance's listeners first. Keyboard follows the
2719   WAI-ARIA tabs pattern with automatic activation: ArrowLeft/Right
2720   move AND select (wrapping), Home/End jump to first/last.
2721   ═══════════════════════════════════════════════════════════════ */
2722YR.initReportTabs = function (config) {
2723  var root = document.getElementById(config.rootId);
2724  if (!root) {
2725    console.warn('YR.initReportTabs: element not found:', config.rootId);
2726    return null;
2727  }
2728
2729  // Re-init on the same live node replaces the previous handlers.
2730  if (root.__yrReportTabs && typeof root.__yrReportTabs.destroy === 'function') {
2731    root.__yrReportTabs.destroy();
2732  }
2733
2734  function tabs() {
2735    return Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
2736  }
2737
2738  function panelFor(tab) {
2739    var id = tab.getAttribute('aria-controls');
2740    return id ? document.getElementById(id) : null;
2741  }
2742
2743  function current() {
2744    var list = tabs();
2745    for (var i = 0; i < list.length; i++) {
2746      if (list[i].getAttribute('aria-selected') === 'true') return list[i];
2747    }
2748    return list[0] || null;
2749  }
2750
2751  function apply(tab, opts) {
2752    if (!tab) return;
2753    var changed = false;
2754    tabs().forEach(function (t) {
2755      var selected = t === tab;
2756      if (selected && t.getAttribute('aria-selected') !== 'true') changed = true;
2757      t.setAttribute('aria-selected', selected ? 'true' : 'false');
2758      t.setAttribute('tabindex', selected ? '0' : '-1');
2759      var panel = panelFor(t);
2760      if (panel) {
2761        if (selected) panel.removeAttribute('hidden');
2762        else panel.setAttribute('hidden', '');
2763      }
2764    });
2765    if (changed && !(opts && opts.silent) && typeof config.onChange === 'function') {
2766      config.onChange(tab.dataset.tab);
2767    }
2768  }
2769
2770  function onClick(e) {
2771    var tab = e.target.closest('[role="tab"]');
2772    if (!tab || !root.contains(tab)) return;
2773    apply(tab);
2774  }
2775
2776  function onKeydown(e) {
2777    var tab = e.target.closest('[role="tab"]');
2778    if (!tab) return;
2779    var list = tabs();
2780    var idx = list.indexOf(tab);
2781    if (idx === -1) return;
2782    var next = null;
2783    if (e.key === 'ArrowRight') next = list[(idx + 1) % list.length];
2784    else if (e.key === 'ArrowLeft') next = list[(idx - 1 + list.length) % list.length];
2785    else if (e.key === 'Home') next = list[0];
2786    else if (e.key === 'End') next = list[list.length - 1];
2787    if (!next) return;
2788    e.preventDefault();
2789    next.focus();
2790    apply(next); // automatic activation: moving focus selects
2791  }
2792
2793  root.addEventListener('click', onClick);
2794  root.addEventListener('keydown', onKeydown);
2795
2796  var api = {
2797    getValue: function () {
2798      var t = current();
2799      return t ? t.dataset.tab : null;
2800    },
2801    setValue: function (value, opts) {
2802      var match = tabs().filter(function (t) { return t.dataset.tab === value; })[0];
2803      if (match) apply(match, opts);
2804    },
2805    destroy: function () {
2806      root.removeEventListener('click', onClick);
2807      root.removeEventListener('keydown', onKeydown);
2808      delete root.__yrReportTabs;
2809    }
2810  };
2811  root.__yrReportTabs = api;
2812
2813  // Normalize DOM to the initial value without firing onChange — the page
2814  // renders its initial panel itself (and re-rendering during init causes
2815  // double work / flicker under AjaxNav).
2816  var initialTab = null;
2817  if (config.initial) {
2818    initialTab = tabs().filter(function (t) { return t.dataset.tab === config.initial; })[0];
2819  }
2820  apply(initialTab || current(), { silent: true });
2821
2822  return api;
2823};
2824
2825
2826// ═══════════════════════════════════════════════════════════════
2827// §31 — CHARTS (pure SVG, zero dependencies) — kit v1.5.0
2828// Builders take (el, data, options) and render into el, EXCEPT sparkHTML which
2829// returns an inline SVG string for use inside grid cells. AA palette from
2830// master §5. Every chart is wrapped in a <figure> with an aria-label.
2831// ═══════════════════════════════════════════════════════════════
2832YR.CHART_COLORS = {
2833  primary: '#12A0DD', success: '#69b64b', warn: '#F0A202', danger: '#D64545',
2834  grid: '#e6ebf0', axis: '#8a97a5', text: '#41505f'
2835};
2836
2837/** Escape an attribute value for inline SVG. @param {*} s @return {string} */
2838function chEsc(s) {
2839  return String(s == null ? '' : s)
2840    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2841    .replace(/"/g, '&quot;');
2842}
2843
2844YR.charts = {
2845  /**
2846   * Inline sparkline SVG string (for grid cells). Draws the value series as a
2847   * filled area + line; flat/empty series render a baseline.
2848   * @param {number[]} values series (e.g. daily minutes).
2849   * @param {object} [opts] { w, h, color }.
2850   * @return {string} SVG markup.
2851   */
2852  sparkHTML: function (values, opts) {
2853    opts = opts || {};
2854    var w = opts.w || 84, h = opts.h || 22, pad = 2;
2855    var color = opts.color || YR.CHART_COLORS.primary;
2856    var data = (values || []).map(function (v) { return +v || 0; });
2857    if (!data.length) data = [0];
2858    var max = Math.max.apply(null, data), min = 0;
2859    var span = (max - min) || 1;
2860    var n = data.length;
2861    var stepX = n > 1 ? (w - pad * 2) / (n - 1) : 0;
2862    var pts = data.map(function (v, i) {
2863      var x = pad + i * stepX;
2864      var y = h - pad - ((v - min) / span) * (h - pad * 2);
2865      return [x, y];
2866    });
2867    var line = pts.map(function (p, i) { return (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join(' ');
2868    var area = line + ' L' + (pad + (n - 1) * stepX).toFixed(1) + ' ' + (h - pad
2868) + ' L' + pad + ' ' + (h - pad) + ' Z';
2869    return '<svg class="yr-spark" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h +
2870      '" role="img" aria-label="' + chEsc(max > 0 ? 'Recent activity trend' : 'No recent activity') + '">' +
2871      '<path d="' + area + '" fill="' + color + '" fill-opacity="0.14"/>' +
2872      '<path d="' + line + '" fill="none" stroke="' + color + '" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>' +
2873      '</svg>';
2874  },
2875
2876  /**
2877   * Horizontal bar chart. data: [{label, value}]. options: { title, unit,
2878   * medianLine (bool), color, formatValue(fn) }.
2879   * @param {Element} el mount.
2880   * @param {Array} data rows.
2881   * @param {object} [options]
2882   * @return {void}
2883   */
2884  hbar: function (el, data, options) {
2885    options = options || {};
2886    if (!el) return;
2887    data = data || [];
2888    if (!data.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; }
2889    var color = options.color || YR.CHART_COLORS.primary;
2890    var max = Math.max.apply(null, data.map(function (d) { return +d.value || 0; })) || 1;
2891    var fmt = options.formatValue || function (v) { return v; };
2892    var rows = data.map(function (d) {
2893      var pct = Math.max(0, (+d.value || 0) / max * 100);
2894      return '<div class="yr-hbar-row"><span class="yr-hbar-label" title="' + chEsc(d.label) + '">' + chEsc(d.label) + '</span>' +
2895        '<span class="yr-hbar-track"><span class="yr-hbar-fill" style="width:' + pct.toFixed(1) + '%;background:' + color + '"></span></span>' +
2896        '<span class="yr-hbar-val">' + chEsc(fmt(d.value)) + '</span></div>';
2897    }).join('');
2898    var caption = '';
2899    if (options.medianLine) {
2900      var vals = data.map(function (d) { return +d.value || 0; }).sort(function (a, b) { return a - b; });
2901      var m = vals.length % 2 ? vals[(vals.length - 1) / 2] : (vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2;
2902      caption = '<div class="yr-hbar-note">Class median: ' + chEsc(fmt(Math.round(m))) + '</div>';
2903    }
2904    el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title,
2905      '<div class="yr-hbar">' + rows + '</div>' + caption);
2906  },
2907
2908  /**
2909   * Line chart over a labelled x-series. data: { labels:[], series:[{name,values,color}] }.
2910   * @param {Element} el mount.
2911   * @param {object} data { labels, series }.
2912   * @param {object} [options] { title, height }.
2913   * @return {void}
2914   */
2915  line: function (el, data, options) {
2916    options = options || {};
2917    if (!el) return;
2918    data = data || {};
2919    var series = data.series || [];
2920    var labels = data.labels || [];
2921    var flat = series.reduce(function (a, s) { return a.concat(s.values || []); }, []);
2922    if (!flat.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; }
2923    var w = 520, h = options.height || 160, padL = 34, padR = 10, padT = 12, padB = 22;
2924    var max = Math.max.apply(null, flat) || 1, min = 0;
2925    var span = (max - min) || 1, n = labels.length || 1;
2926    var stepX = n > 1 ? (w - padL - padR) / (n - 1) : 0;
2927    var yOf = function (v) { return h - padB - ((v - min) / span) * (h - padT - padB); };
2928    var xOf = function (i) { return padL + i * stepX; };
2929    var grid = '';
2930    for (var g = 0; g <= 2; g++) {
2931      var gy = padT + (h - padT - padB) * g / 2;
2932      grid += '<line x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (w - padR) + '" y2="' + gy.toFixed(1) +
2933        '" stroke="' + YR.CHART_COLORS.grid + '" stroke-width="1"/>';
2934    }
2935    var paths = series.map(function (s, si) {
2936      var c = s.color || [YR.CHART_COLORS.primary, YR.CHART_COLORS.success, YR.CHART_COLORS.warn][si % 3];
2937      var d = (s.values || []).map(function (v, i) { return (i ? 'L' : 'M') + xOf(i).toFixed(1) + ' ' + yOf(+v || 0).toFixed(1); }).join(' ');
2938      return '<path d="' + d + '" fill="none" stroke="' + c + '" stroke-width="2" stroke-linejoin="round"/>';
2939    }).join('');
2940    var svg = '<svg width="100%" viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none" class="yr-chart-svg" role="img" aria-label="' +
2941      chEsc(options.ariaLabel || options.title) + '">' + grid + paths +
2942      '<text x="' + padL + '" y="' + (padT - 2) + '" font-size="9" fill="' + YR.CHART_COLORS.axis + '">' + chEsc(Math.round(max)) + '</text>' +
2943      '</svg>';
2944    el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, svg);
2945  },
2946
2947  /**
2948   * Scatter/quadrant chart. data: [{x, y, label}]. options: { title, xLabel,
2949   * yLabel, quadrantLabels:[tl,tr,bl,br], onPoint(label) }.
2950   * @param {Element} el mount.
2951   * @param {Array} data points.
2952   * @param {object} [options]
2953   * @return {void}
2954   */
2955  scatter: function (el, data, options) {
2956    options = options || {};
2957    if (!el) return;
2958    data = (data || []).filter(function (d) { return d.x != null && d.y != null; });
2959    if (!data.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; }
2960    var w = 320, h = 220, pad = 26;
2961    var xs = data.map(function (d) { return +d.x; }), ys = data.map(function (d) { return +d.y; });
2962    var xmax = Math.max.apply(null, xs) || 1, ymax = Math.max.apply(null, ys) || 1;
2963    var med = function (a) { var s = a.slice().sort(function (p, q) { return p - q; }); return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2; };
2964    var mx = med(xs), my = med(ys);
2965    var xOf = function (v) { return pad + (v / xmax) * (w - pad * 2); };
2966    var yOf = function (v) { return h - pad - (v / ymax) * (h - pad * 2); };
2967    var pts = data.map(function (d) {
2968      return '<circle class="yr-scatter-pt" cx="' + xOf(+d.x).toFixed(1) + '" cy="' + yOf(+d.y).toFixed(1) +
2969        '" r="4" fill="' + YR.CHART_COLORS.primary + '" fill-opacity="0.75" data-label="' + chEsc(d.label) + '"><title>' +
2970        chEsc(d.label + ' — ' + (options.xLabel || 'x') + ' ' + d.x + ', ' + (options.yLabel || 'y') + ' ' + d.y) + '</title></circle>';
2971    }).join('');
2972    var svg = '<svg width="100%" viewBox="0 0 ' + w + ' ' + h + '" class="yr-chart-svg" role="img" aria-label="' + chEsc(options.ariaLabel || options.title) + '">' +
2973      '<line x1="' + xOf(mx).toFixed(1) + '" y1="' + pad + '" x2="' + xOf(mx).toFixed(1) + '" y2="' + (h - pad) + '" stroke="' + YR.CHART_COLORS.grid + '" stroke-dasharray="3 3"/>' +
2974      '<line x1="' + pad + '" y1="' + yOf(my).toFixed(1) + '" x2="' + (w - pad) + '" y2="' + yOf(my).toFixed(1) + '" stroke="' + YR.CHART_COLORS.grid + '" stroke-dasharray="3 3"/>' +
2975      pts + '</svg>';
2976    el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, svg);
2977    if (options.onPoint) {
2978      el.querySelectorAll('.yr-scatter-pt').forEach(function (c) {
2979        c.style.cursor = 'pointer';
2980        c.addEventListener('click', function () { options.onPoint(c.getAttribute('data-label')); });
2981      });
2982    }
2983  },
2984
2985  /**
2986   * Heatmap grid. rows: [{label, cells:[{v}]}], cols: [labels]. Intensity is
2987   * value / maxValue mapped to the primary color opacity.
2988   * @param {Element} el mount.
2989   * @param {object} data { rows, cols }.
2990   * @param {object} [options] { title, color }.
2991   * @return {void}
2992   */
2993  heatmap: function (el, data, options) {
2994    options = options || {};
2995    if (!el) return;
2996    data = data || {};
2997    var rows = data.rows || [], cols = data.cols || [];
2998    var allV = rows.reduce(function (a, r) { return a.concat((r.cells || []).map(function (c) { return +c.v || 0; })); }, []);
2999    var max = Math.max.apply(null, allV.length ? allV : [0]) || 1;
3000    if (!rows.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; }
3001    var color = options.color || YR.CHART_COLORS.primary;
3002    var head = '<div class="yr-hm-row yr-hm-head"><span class="yr-hm-label"></span>' +
3003      cols.map(function (c) { return '<span class="yr-hm-col">' + chEsc(c) + '</span>'; }).join('') + '</div>';
3004    var body = rows.map(function (r) {
3005      var cells = (r.cells || []).map(function (c) {
3006        var op = (+c.v || 0) / max;
3007        return '<span class="yr-hm-cell" style="background:' + color + ';opacity:' + (op ? (0.12 + op * 0.88).toFixed(2) : 0.06) +
3008          '" title="' + chEsc(r.label + ': ' + (c.v || 0)) + '"></span>';
3009      }).join('');
3010      return '<div class="yr-hm-row"><span class="yr-hm-label" title="' + chEsc(r.label) + '">' + chEsc(r.label) + '</span>' + cells + '</div>';
3011    }).join('');
3012    el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, '<div class="yr-hm">' + head + body + '</div>');
3013  },
3014
3015  /** @return {string} figure wrapper. */
3016  _fig: function (title, aria, inner) {
3017    return '<figure class="yr-figure" aria-label="' + chEsc(aria || title) + '">' +
3018      (title ? '<figcaption class="yr-fig-title">' + chEsc(title) + '</figcaption>' : '') + inner + '</figure>';
3019  },
3020
3021  /** @return {string} empty-state figure. */
3022  _empty: function (title, msg) {
3023    return '<figure class="yr-figure yr-figure-empty">' +
3024      (title ? '<figcaption class="yr-fig-title">' + chEsc(title) + '</figcaption>' : '') +
3025      '<div class="yr-chart-empty">' + chEsc(msg || 'No data') + '</div></figure>';
3026  }
3027};
3028
3029
3030window.YR = YR;
3031
3032/* ─────────────────────────────────────────────────────────────────────────
3033   v1.5.0 — Persistent surface registry + lifecycle ownership (#2394)
3034
3035   Central home for body-hoisted staff-v6 modals to register themselves ONCE
3036   at parse time, so the AjaxNav shell can rehydrate them after every main-area
3037   swap without each view maintaining a hand-written list of persist() calls.
3038
3039   Coupling direction matters: yeti-report.js is POS_HEAD and parses BEFORE
3040   ajax-nav.js, so this file CANNOT reach into AjaxNav at parse time. ajax-nav.js
3041   PULLS this registry from its runHydrators() instead. `window.YR` IS available
3042   when page modal scripts parse (they load after this POS_HEAD file), so a modal
3043   calling YR.registerPersistentModal(...) at the bottom of its IIFE is safe.
3044   ───────────────────────────────────────────────────────────────────────── */
3045YR._persistentModals = YR._persistentModals || {};
3046
3047/**
3048 * Register a body-hoisted modal's API so the shell can rehydrate it after
3049 * AjaxNav swaps. Re-registering the same name replaces the prior API.
3050 *
3051 * @param {string} name - unique modal key (e.g. 'reset-password').
3052 * @param {Object} api  - the modal's public API; should expose persist()
3053 *                        (preferred) or init() for rehydration.
3054 * @param {Object} [metadata] ownership and navigation policy. Legacy entries
3055 *                            default to `{owner:'page', navigation:'close'}`.
3056 * @return {void}
3057 */
3058YR.registerPersistentModal = function (name, api, metadata) {
3059  if (!name || !api) return;
3060  metadata = metadata || {};
3061  YR._persistentModals[name] = {
3062    api: api,
3063    metadata: {
3064      owner: metadata.owner === 'shell' || metadata.owner === 'shell-critical'
3065        ? metadata.owner : 'page',
3066      navigation: metadata.navigation === 'preserve' ? 'preserve' : 'close',
3067      css: Array.isArray(metadata.css) ? metadata.css.slice() : []
3068    }
3069  };
3070};
3071
3072/**
3073 * Normalize registry entries created before ownership metadata was introduced.
3074 *
3075 * @param {Object} entry raw registry value.
3076 * @return {Object} `{api, metadata}` entry.
3077 */
3078function yrPersistentEntry(entry) {
3079  if (entry && entry.api) return entry;
3080  return {
3081    api: entry,
3082    metadata: { owner: 'page', navigation: 'close', css: [] }
3083  };
3084}
3085
3086/**
3087 * Rehydrate every registered persistent modal once. Called by ajax-nav.js's
3088 * runHydrators() after each main-area swap and on the initial full page load.
3089 * Prefers persist(), falls back to init(). A throwing modal is isolated.
3090 *
3091 * @return {void}
3092 */
3093YR.persistRegisteredModals = function (context) {
3094  var errors = [];
3095  Object.keys(YR._persistentModals).forEach(function (name) {
3096    var api = yrPersistentEntry(YR._persistentModals[name]).api;
3097    try {
3098      if (api && typeof api.persist === 'function') api.persist(context || {});
3099      else if (api && typeof api.init === 'function') api.init(context || {});
3100    } catch (e) {
3101      errors.push({ owner: name, error: e });
3102      console.error('[YR] persistent modal failed:', name, e);
3103    }
3104  });
3105  return errors;
3106};
3107
3108/**
3109 * Ask every registered modal to close before a main-area navigation. This is
3110 * the semantic close barrier: modules can remove listeners, restore focus,
3111 * and release their own locks before the outgoing page disappears.
3112 *
3113 * @param {Object} [context] navigation context supplied by the shell.
3114 * @return {void}
3115 */
3116YR.closeRegisteredModals = function (context) {
3117  var errors = [];
3118  Object.keys(YR._persistentModals).forEach(function (name) {
3119    var entry = yrPersistentEntry(YR._persistentModals[name]);
3120    var api = entry.api;
3121    if (entry.metadata.navigation === 'preserve') return;
3122    try {
3123      if (api && typeof api.closeForNavigation === 'function') {
3124        api.closeForNavigation(context || {});
3125      } else if (api && typeof api.close === 'function') {
3126        api.close(context || {});
3127      }
3128    } catch (e) {
3129      errors.push({ owner: name, error: e });
3130      console.error('[YR] persistent modal close failed:', name, e);
3131    }
3132  });
3133  return errors;
3134};
3135
3136/**
3137 * Run shell-surface navigation guards before a hash/request commits. Guards
3138 * may return a boolean or a promise resolving to a boolean.
3139 *
3140 * @param {Object} context proposed navigation context.
3141 * @return {Promise<boolean>} whether navigation may continue.
3142 */
3143YR.beforeNavigation = function (context) {
3144  var names = Object.keys(YR._persistentModals);
3145  return names.reduce(function (chain, name) {
3146    return chain.then(function (allowed) {
3147      if (!allowed) return false;
3148      var entry = yrPersistentEntry(YR._persistentModals[name]);
3149      if (entry.metadata.owner === 'page' || !entry.api ||
3150          typeof entry.api.beforeNavigation !== 'function') return true;
3151      return Promise.resolve(entry.api.beforeNavigation(context || {}))
3152        .then(function (result) { return result !== false; });
3153    });
3154  }, Promise.resolve(true));
3155};
3156
3157/**
3158 * Whether a node belongs to a registered shell-owned surface. Used by the
3159 * AjaxNav residue sweep so it never hides shell-critical UI.
3160 *
3161 * @param {Element}
3161 node candidate overlay node.
3162 * @return {boolean} true when a shell registry entry owns the node.
3163 */
3164YR.isRegisteredShellNode = function (node) {
3165  if (!node) return false;
3166  return Object.keys(YR._persistentModals).some(function (name) {
3167    var entry = yrPersistentEntry(YR._persistentModals[name]);
3168    if (entry.metadata.owner === 'page' || !entry.api ||
3169        typeof entry.api.debugNode !== 'function') return false;
3170    var owned = entry.api.debugNode();
3171    return !!owned && (owned === node || owned.contains(node) || node.contains(owned));
3172  });
3173};
3174
3175/**
3176 * Return non-sensitive modal ownership/open-state data for tests and traces.
3177 *
3178 * @return {Array<Object>} registry snapshot.
3179 */
3180YR.getPersistentModalSnapshot = function () {
3181  return Object.keys(YR._persistentModals).map(function (name) {
3182    var entry = yrPersistentEntry(YR._persistentModals[name]);
3183    var api = entry.api;
3184    return {
3185      name: name,
3186      owner: entry.metadata.owner,
3187      navigation: entry.metadata.navigation,
3188      open: !!(api && typeof api.isOpen === 'function' && api.isOpen()),
3189      connected: !!(api && typeof api.debugNode === 'function' && api.debugNode() && api.debugNode().isConnected)
3190    };
3191  });
3192};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.