PageSourceSearch

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

js typingagent.com collected 2026-10-01 14:07:45 UTC 145,429 bytes, 3,280 lines download raw bytes

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

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.