Private/Get-DhJsNav.ps1

function Get-DhJsNav {
    # Auto-split fragment of the dashboard runtime JS (see Get-DhJsContent).
    return @'
  /* =========================================================================
     NAVIGATION (v2.0.0) — one depth-agnostic menu tree

     A section is visible IFF it carries panel-active. NOTHING else ever touches
     style.display on a section. State is RECOMPUTED WHOLE from the selected
     path, never patched incrementally.

     This replaces the 1.x showGroup / showSubPanel / showSubGroup /
     _showFlatPanel quartet, which mutated overlapping subsets of the state
     using two competing mechanisms (an inline style.display AND the
     panel-active class). That is what made a block declared with
     -NavGroup + -NavSubGroup permanently unreachable (readme-dev gotcha #26):
     the hide branch cleared both, the show branch restored only one.

     There are no show/hide branches left to keep symmetrical. Re-applying the
     same path is a no-op, and the result depends only on the final path, never
     on the route taken to reach it.

     Nothing below mentions a number of levels. Depth is data.
     ========================================================================= */

  /* ── Sync nav sticky top to actual header height ────────────────── */
  function syncNavTop() {
    var header = document.querySelector('.report-header');
    var nav = document.getElementById('report-nav');
    if (!header || !nav) return;
    document.documentElement.style.setProperty('--header-height', header.offsetHeight + 'px');
  }

  /* ── Collapsible table chrome ──────────────────────────────────────
     Build-DhTableSections emits button#tsect-toggle-<id> and
     div#tsect-body-<id>; .open on the body is what the CSS unfolds.
     2.0.0 bound a class that was never emitted (.table-collapse-toggle),
     so a -DefaultOpen:$false table could never be opened. Fixed in 2.1.4;
     this is the 1.5.1 binding again. */
  /* ==== DH-TABLE-COLLAPSE-START ==== (driven by Tests/js/nav.test.js) */
  function initTableCollapsibles() {
    document.querySelectorAll('.table-section-toggle').forEach(function (btn) {
      if (btn._dhWired) return;
      var body = document.getElementById(btn.id.replace(/^tsect-toggle-/, 'tsect-body-'));
      if (!body) return;
      btn._dhWired = true;
      var chev = btn.querySelector('.table-section-chevron');
      btn.addEventListener('click', function () {
        var open = body.classList.toggle('open');
        btn.classList.toggle('open', open);
        btn.setAttribute('aria-expanded', open ? 'true' : 'false');
        if (chev) chev.innerHTML = open ? '&#9662;' : '&#9656;';
        /* a table first laid out while folded measured nothing — re-render */
        var eng = engines[btn.id.replace(/^tsect-toggle-/, '')];
        if (open && eng) eng.render();
      });
    });
  }
  /* ==== DH-TABLE-COLLAPSE-END ==== */

  /* ─────────────────────────────────────────────────────────────────
     THE MATCHER — mirrors Test-DhNavMatch in PowerShell exactly.
     Extracted verbatim by Tests/js/nav.test.js via these sentinels, so the
     two copies cannot drift apart unnoticed. Keep them.
     ───────────────────────────────────────────────────────────────── */
  /* ==== DH-NAV-MATCHER-START ==== */
  function navPathOf(sec) {
    try { return JSON.parse(sec.dataset.navpath || '[]'); } catch (e) { return []; }
  }
  function navMatches(sec, path) {
    var scope = (sec.dataset.navscope || 'exact').toLowerCase();
    if (scope === 'global') return true;
    var p = navPathOf(sec);
    /* the reserved last segment '*' selects an 'All' view: everything at or below the parent,
       whatever its scope, plus Subtree items declared above it (mirrors Test-DhNavMatch) */
    if (path.length && path[path.length - 1] === '*') {
      var base = path.slice(0, -1), n = Math.min(p.length, base.length);
      for (var k = 0; k < n; k++) { if (p[k] !== base[k]) return false; }
      return p.length >= base.length ? true : scope === 'subtree';
    }
    if (p.length > path.length) return false; /* declared deeper than selected */
    for (var i = 0; i < p.length; i++) { if (p[i] !== path[i]) return false; }
    return scope === 'subtree' || p.length === path.length;
  }
  /* ==== DH-NAV-MATCHER-END ==== */

  var currentNavPath = [];

  function navSections() { return document.querySelectorAll('[data-navpath]'); }

  /* ── Tree, derived from the DOM ──────────────────────────────────
     Built from the same data-navpath attributes the matcher reads, so a menu
     strip can never describe a view the content does not have, nor miss one it
     does. That drift was defect #2 in the proposal. */
  function buildNavTree() {
    var root = { seg: '', path: [], label: '', children: {}, order: [], hasContent: false };
    navSections().forEach(function (sec) {
      if (sec.classList.contains('alert-banner')) return; /* a banner follows the menu, it never adds to it */
      var p = navPathOf(sec);
      if (!p.length) return; /* global: no menu node */
      var scope = (sec.dataset.navscope || 'exact').toLowerCase();
      var node = root;
      for (var i = 0; i < p.length; i++) {
        if (!node.children[p[i]]) {
          node.children[p[i]] = { seg: p[i], path: p.slice(0, i + 1), label: p[i],
                                  children: {}, order: [], hasContent: false, tableId: null,
                                  seq: Infinity, navOrder: 0 };
          node.order.push(p[i]);
        }
        node = node.children[p[i]];
        /* A node inherits the EARLIEST declaration that passes through it, so an
           ancestor created late by a deep path still sorts by its first mention. */
        var seq = parseInt(sec.dataset.navseq || '0', 10);
        if (seq < node.seq) node.seq = seq;
        /* -NavOrder orders the item's OWN node among its siblings, not its ancestors
           (Get-DhNavTree does the same). It used to be applied at every level, so an
           ordered leaf also moved its parent ahead of unordered siblings. */
        var ord = parseInt(sec.dataset.navorder || '0', 10);
        if (i === p.length - 1 && ord !== 0 && node.navOrder === 0) node.navOrder = ord;
      }
      if (scope === 'exact') node.hasContent = true;
      if (sec.dataset.navlabel) node.label = sec.dataset.navlabel;
      /* A table section sitting exactly here lets its chip carry a row-count
         badge, replacing the 1.x .nav-badge that lived in the flat nav link. */
      if (scope === 'exact' && sec.id && sec.id.indexOf('section-') === 0) {
        node.tableId = sec.id.slice('section-'.length);
      }
    });
    /* New-DhDashboard -NavTree: the menu skeleton. It creates nodes before anything sits on them (an
       empty one shows a "no content" note), puts its own order ahead of declaration order, and supplies
       a label and -NavOrder only where an item gave none (an item's own values win). */
    var navElT = document.getElementById('report-nav');
    if (navElT && navElT.dataset.navtree) {
      var skel = [];
      try { skel = JSON.parse(navElT.dataset.navtree); } catch (e) { skel = []; }
      skel.forEach(function (s, si) {
        var nd = root;
        for (var q = 0; q < s.path.length; q++) {
          if (!nd.children[s.path[q]]) {
            nd.children[s.path[q]] = { seg: s.path[q], path: s.path.slice(0, q + 1), label: s.path[q],
                                       children: {}, order: [], hasContent: false, tableId: null, seq: Infinity, navOrder: 0 };
            nd.order.push(s.path[q]);
          }
          nd = nd.children[s.path[q]];
          if (-100000 + si < nd.seq) nd.seq = -100000 + si;
        }
        if (s.label && nd.label === nd.seg) nd.label = s.label;
        if (s.order && nd.navOrder === 0) nd.navOrder = s.order;
      });
    }
    /* New-DhDashboard -NavAllLabel: an addressable 'All' child ('*') on a group node. It lists the parent's
       own content and everything below it, and sorts first among its siblings. */
    var navEl = document.getElementById('report-nav');
    if (navEl && (navEl.dataset.navall || navEl.dataset.navallall)) {
      var allMap = {}, allDefault = navEl.dataset.navallall || '';
      try { (JSON.parse(navEl.dataset.navall || '[]')).forEach(function (a) { allMap[JSON.stringify(a.path)] = a.label; }); } catch (e) {}
      (function addAll(node) {
        Object.keys(node.children).forEach(function (k) { addAll(node.children[k]); });
        if (node === root || !node.order.length) return;
        var lab = allMap[JSON.stringify(node.path)] || allDefault;
        if (lab && !node.children['*']) {
          node.children['*'] = { seg: '*', path: node.path.concat('*'), label: lab, children: {}, order: [], hasContent: true,
                                 tableId: null, seq: -Infinity, navOrder: -1 };
          node.order.push('*');
        }
      })(root);
    }
    /* Sort every level: explicit -NavOrder first, then declaration order.
       Mirrors Get-DhNavChildOrder in PowerShell. */
    (function sortTree(node) {
      node.order.sort(function (a, b) {
        var A = node.children[a], B = node.children[b];
        var ao = A.navOrder !== 0 ? 0 : 1, bo = B.navOrder !== 0 ? 0 : 1;
        if (ao !== bo) return ao - bo;
        if (A.navOrder !== B.navOrder) return A.navOrder - B.navOrder;
        return A.seq - B.seq;
      });
      node.order.forEach(function (k) { sortTree(node.children[k]); });
    })(root);
    return root;
  }

  function navNodeAt(tree, path) {
    var node = tree;
    for (var i = 0; i < path.length; i++) {
      if (!node.children[path[i]]) return null;
      node = node.children[path[i]];
    }
    return node;
  }

  /* Descend to the first node that holds content of its own, then STOP.
     At depth <= 3 this is identical to descend-to-first-leaf; deeper, it stops
     one click teleporting the reader several levels down. */
  function navAutoDescend(tree, path) {
    var out = path.slice();
    var node = navNodeAt(tree, out);
    if (!node) return out;
    while (!node.hasContent && node.order.length) {
      var next = node.order.filter(function (k) { return k !== '*'; })[0]; /* the All view is never the default */
      if (next === undefined) break;
      out.push(next);
      node = node.children[next];
    }
    return out;
  }

  /* ── THE ONE RULE ────────────────────────────────────────────────── */
  function applyNav(path) {
    currentNavPath = (path || []).slice();
    /* v2.3.0: the detail drawer lives on <body>, so it would otherwise keep showing
       a record from a view that is no longer visible. */
    if (typeof dhDrawerClose === 'function') dhDrawerClose(true);
    navSections().forEach(function (sec) {
      sec.classList.toggle('panel-active', navMatches(sec, currentNavPath));
    });
    /* the menu is rebuilt on every change: put keyboard focus back on the same item */
    var fa = document.activeElement, focusKey = null;
    if (fa && fa.dataset && fa.dataset.navto && fa.closest && fa.closest('#report-nav, #nav-sidebar')) focusKey = fa.dataset.navto;
    renderNavMenu(currentNavPath);
    if (focusKey) {
      var again = document.querySelectorAll('[data-navto]');
      for (var fi = 0; fi < again.length; fi++) if (again[fi].dataset.navto === focusKey) { try { again[fi].focus({ preventScroll: true }); } catch (e) {} break; }
    }
    updateEmptyView();
    if (typeof URLState !== 'undefined' && URLState.save) URLState.save();
    if (typeof updateNavBadges === 'function') updateNavBadges();
  }

  /* v2.5.0 - a selected view with nothing of its own (an empty -NavTree node, a deep link to a
     renamed node) says so instead of showing a blank page. Banners, global and path-less items do
     not count as content. */
  function updateEmptyView() {
    var el = document.getElementById('nav-empty');
    var own = false;
    navSections().forEach(function (sec) {
      if (own || sec.classList.contains('alert-banner') || !sec.classList.contains('panel-active')) return;
      if ((sec.dataset.navscope || 'exact').toLowerCase() === 'global' || !navPathOf(sec).length) return;
      own = true;
    });
    if (own || !currentNavPath.length) { if (el) el.hidden = true; return; }
    if (!el) {
      el = document.createElement('div');
      el.id = 'nav-empty'; el.className = 'nav-empty'; el.setAttribute('role', 'status');
      var host = document.querySelector('main') || document.body;
      host.appendChild(el);
    }
    var nav = document.getElementById('report-nav');
    var parent = currentNavPath.length > 1 ? currentNavPath.slice(0, -1) : null;
    el.innerHTML = '<strong>No content in this view.</strong>' +
      (nav && nav.dataset.navstrict === 'true' ? ' Nothing is declared at <code>' + esc(currentNavPath.join(' / ')) + '</code>: check the -NavPath of the items that should be here.' : '') +
      (parent ? ' <button type="button" class="nav-empty-back">Back to ' + esc(parent[parent.length - 1] === '*' ? 'the group' : parent.join(' / ')) + '</button>' : '');
    el.hidden = false;
    var back = el.querySelector('.nav-empty-back');
    if (back) back.addEventListener('click', function () { applyNav(parent); });
  }
  /* ── Menu rendering: one tree, two presentations ─────────────────
     Both read the SAME tree and emit the SAME chip semantics; only the
     arrangement differs. Nothing below is aware of a level count. */
  function navLayout() {
    var nav = document.getElementById('report-nav');
    return (nav && nav.dataset.navlayout) ? nav.dataset.navlayout : 'strips';
  }

  function renderNavMenu(path) {
    if (navLayout() === 'sidebar') { renderNavSidebar(path); renderNavStrips(path, true); }
    else { renderNavSidebar(path, true); renderNavStrips(path); }
    renderNavCrumb(path);
  }

  /* Sidebar rail — the whole tree at once, recovering the ~190px of sticky
     vertical chrome the strips consume. Required at depth 4+ (plan-v2.md D6). */
  function renderNavSidebar(path, hide) {
    var host = document.getElementById('nav-sidebar');
    if (!host) return;
    if (hide) { host.style.display = 'none'; host.innerHTML = ''; return; }
    host.style.display = '';
    host.innerHTML = '';
    document.body.classList.add('has-nav-sidebar');

    var tree = buildNavTree();
    (function walk(node, depth, container) {
      node.order.forEach(function (seg) {
        var child = node.children[seg];
        var onPath = path[depth] === seg;
        var selected = onPath && path.length === child.path.length;

        var a = document.createElement('a');
        a.className = 'nav-rail-item nav-rail-d' + depth +
                      (selected ? ' nav-rail-active' : '') +
                      (onPath ? ' nav-rail-onpath' : '');
        a.href = '#';
        a.style.paddingLeft = (10 + depth * 14) + 'px';
        a.textContent = child.label;
        a.setAttribute('role', 'treeitem');
        a.setAttribute('aria-selected', selected ? 'true' : 'false');
        a.setAttribute('aria-level', String(depth + 1));
        if (child.order.length) a.setAttribute('aria-expanded', onPath ? 'true' : 'false');
        if (selected) a.setAttribute('aria-current', 'page');
        a.tabIndex = selected || (!path.length && depth === 0 && !container.querySelector('[tabindex="0"]')) ? 0 : -1;
        a.dataset.navto = JSON.stringify(child.path); /* v2.4.0: NavBadge and search address items by path */
        if (child.tableId) {
          var b = document.createElement('span');
          b.className = 'nav-badge';
          b.dataset.table = child.tableId;
          a.appendChild(b);
        }
        a.addEventListener('click', function (e) {
          e.preventDefault();
          applyNav(navAutoDescend(buildNavTree(), child.path));
        });
        container.appendChild(a);

        /* Only expand the branch the reader is actually on - a fully expanded
           deep tree is as unreadable as four stacked strips. */
        if (onPath && child.order.length) { walk(child, depth + 1, container); }
      });
    })(tree, 0, host);
  }

  /* ── Strips, rendered from a loop ────────────────────────────────
     N strips, no fixed element ids and no L1/L2/L3 naming anywhere. This is
     what makes a 4th level a config change rather than a rewrite. */
  function renderNavStrips(path, hide) {
    var host = document.getElementById('nav-strips');
    if (!host) return;
    var topHostEl = document.getElementById('nav-strips-top');
    if (hide) {
      host.style.display = 'none'; host.innerHTML = '';
      if (topHostEl) { topHostEl.style.display = 'none'; topHostEl.innerHTML = ''; }
      return;
    }
    host.style.display = '';
    if (topHostEl) topHostEl.style.display = '';
    document.body.classList.remove('has-nav-sidebar');
    var tree = buildNavTree();
    host.innerHTML = '';

    /* Depth 0 renders INSIDE .nav-inner, on the same line as the Top / density /
       theme buttons - that is where the 1.x group tabs lived, and moving them to
       their own row cost a whole line of vertical chrome for no benefit.
       Levels 1+ get their own strips below. */
    var topHost = document.getElementById('nav-strips-top');
    if (topHost) topHost.innerHTML = '';

    var node = tree;
    var depth = 0;
    while (node && node.order.length) {
      var strip = null, inner;
      if (depth === 0 && topHost) {
        inner = topHost; /* chips go straight into nav-inner */
      } else {
        strip = document.createElement('div');
        strip.className = 'nav-strip nav-strip-' + depth; /* depth, not a level name */
        strip.setAttribute('role', 'tablist');
        strip.setAttribute('aria-label', 'Subsections');
        inner = document.createElement('div');
        inner.className = 'nav-strip-inner';
      }

      (function (currentNode, currentDepth) {
        currentNode.order.forEach(function (seg) {
          var child = currentNode.children[seg];
          var selected = path[currentDepth] === seg;
          var a = document.createElement('a');
          a.className = 'nav-chip' + (selected ? ' nav-chip-active' : '');
          a.href = '#';
          a.textContent = child.label;
          a.setAttribute('role', 'tab');
          a.setAttribute('aria-selected', selected ? 'true' : 'false');
          a.tabIndex = selected ? 0 : -1; /* roving tabindex: one tab stop per strip */
          if (selected && path.length === child.path.length) a.setAttribute('aria-current', 'page');
          a.dataset.navto = JSON.stringify(child.path);
          if (child.tableId) {
            var badge = document.createElement('span');
            badge.className = 'nav-badge';
            badge.dataset.table = child.tableId;
            a.appendChild(badge);
          }
          a.addEventListener('click', function (e) {
            e.preventDefault();
            applyNav(navAutoDescend(buildNavTree(), child.path));
          });
          inner.appendChild(a);
        });
      })(node, depth);

      if (strip) { strip.appendChild(inner); host.appendChild(strip); }
      dhNavFitStrip(inner); /* "More" menu when the chips would wrap past two rows */
      dhNavTabStop(inner);

      if (path[depth] === undefined || !node.children[path[depth]]) break;
      node = node.children[path[depth]];
      depth++;
    }
  }

  /* Breadcrumb — mandatory once a tree is 4+ deep, harmless shallower. */
  function renderNavCrumb(path) {
    var host = document.getElementById('nav-crumb');
    if (!host) return;
    if (path.length < 4) { host.innerHTML = ''; host.style.display = 'none'; return; }
    host.style.display = '';
    host.innerHTML = '';
    var tree = buildNavTree();
    host.setAttribute('role', 'navigation');
    path.forEach(function (seg, i) {
      var node = navNodeAt(tree, path.slice(0, i + 1));
      var a = document.createElement('a');
      a.className = 'nav-crumb-item';
      if (i === path.length - 1) a.setAttribute('aria-current', 'page');
      a.href = '#';
      a.textContent = node ? node.label : seg;
      a.addEventListener('click', function (e) {
        e.preventDefault();
        applyNav(path.slice(0, i + 1));
      });
      host.appendChild(a);
      if (i < path.length - 1) {
        var sep = document.createElement('span');
        sep.className = 'nav-crumb-sep';
        sep.textContent = '›';
        host.appendChild(sep);
      }
    });
  }

  /* ── Overflow and keyboard (v2.5.0) ──────────────────────────────────────
     dhNavFit is pure: widths in, which chips stay visible out. dhNavFitStrip
     measures a rendered strip and, when its chips would wrap past two rows,
     moves the ones that do not fit into a "More" menu (the selected chip always
     stays visible). -NavOverflow Wrap turns it off. */
  function dhNavFit(widths, avail, gap, maxRows, moreW, active) {
    var n = widths.length, all = [], i;
    for (i = 0; i < n; i++) all.push(i);
    function rowsFor(idx, extra) {
      var ws = idx.map(function (k) { return widths[k]; }), rows = 1, x = 0;
      if (extra) ws.push(extra);
      ws.forEach(function (w) {
        if (x > 0 && x + gap + w > avail) { rows++; x = w; } else { x += (x > 0 ? gap : 0) + w; }
      });
      return rows;
    }
    if (rowsFor(all, 0) <= maxRows) return { visible: all, overflow: [] };
    var k = n;
    while (k > 1) {
      var vis = all.slice(0, k);
      if (active >= k) vis[k - 1] = active;
      if (rowsFor(vis, moreW) <= maxRows) break;
      k--;
    }
    var visible = all.slice(0, Math.max(1, k));
    if (active >= 0 && visible.indexOf(active) === -1) visible[visible.length - 1] = active;
    visible.sort(function (a, b) { return a - b; });
    return { visible: visible, overflow: all.filter(function (x) { return visible.indexOf(x) === -1; }) };
  }

  function dhNavFitStrip(inner) {
    var nav = document.getElementById('report-nav');
    if (!nav || nav.dataset.navoverflow === 'wrap' || !inner.isConnected) return;
    var chips = Array.prototype.slice.call(inner.children).filter(function (c) { return c.classList.contains('nav-chip'); });
    if (chips.length < 4 || inner.clientWidth < 80) return;
    var tops = {};
    chips.forEach(function (c) { tops[c.offsetTop] = 1; });
    if (Object.keys(tops).length <= 2) return; /* fits in two rows: leave it */
    var gap = parseFloat(getComputedStyle(inner).columnGap) || 4;
    var active = -1;
    chips.forEach(function (c, i) { if (c.classList.contains('nav-chip-active')) active = i; });
    var fit = dhNavFit(chips.map(function (c) { return c.offsetWidth; }), inner.clientWidth, gap, 2, 84, active);
    if (!fit.overflow.length) return;
    var wrap = document.createElement('div'), btn = document.createElement('button'), menu = document.createElement('div');
    wrap.className = 'nav-more';
    btn.type = 'button'; btn.className = 'nav-more-btn';
    btn.setAttribute('aria-haspopup', 'menu'); btn.setAttribute('aria-expanded', 'false');
    btn.textContent = 'More (' + fit.overflow.length + ') \u25BE';
    btn.tabIndex = -1;
    menu.className = 'nav-more-menu'; menu.setAttribute('role', 'menu'); menu.hidden = true;
    fit.overflow.forEach(function (i) {
      chips[i].setAttribute('role', 'menuitem');
      chips[i].tabIndex = -1;
      menu.appendChild(chips[i]);
    });
    btn.addEventListener('click', function () { dhNavMoreToggle(btn, menu); });
    wrap.appendChild(btn); wrap.appendChild(menu);
    inner.appendChild(wrap);
  }
  function dhNavMoreToggle(btn, menu, forceOpen) {
    var open = forceOpen === undefined ? menu.hidden : forceOpen;
    menu.hidden = !open;
    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
    if (open) { var first = menu.querySelector('[data-navto]'); if (first) first.focus(); }
  }
  /* one tab stop per strip: the selected chip, else the first */
  function dhNavTabStop(inner) {
    var chips = Array.prototype.slice.call(inner.children).filter(function (c) { return c.classList.contains('nav-chip') || c.classList.contains('nav-more-btn'); });
    if (!chips.length || chips.some(function (c) { return c.tabIndex === 0; })) return;
    chips[0].tabIndex = 0;
  }

  function dhNavKeydown(e) {
    var t = e.target, key = e.key, el, items, i;
    if (!t || !t.closest) return;
    if (key === 'Escape') {
      var menuOpen = document.querySelector('.nav-more-menu:not([hidden])');
      if (menuOpen) { var b = menuOpen.parentNode.querySelector('.nav-more-btn'); dhNavMoreToggle(b, menuOpen, false); b.focus(); e.preventDefault(); }
      return;
    }
    /* inside the More menu */
    el = t.closest('.nav-more-menu [data-navto]');
    if (el) {
      items = Array.prototype.slice.call(el.parentNode.querySelectorAll('[data-navto]')); i = items.indexOf(el);
      if (key === 'ArrowDown') { items[(i + 1) % items.length].focus(); e.preventDefault(); }
      else if (key === 'ArrowUp') { items[(i - 1 + items.length) % items.length].focus(); e.preventDefault(); }
      else if (key === 'Home') { items[0].focus(); e.preventDefault(); }
      else if (key === 'End') { items[items.length - 1].focus(); e.preventDefault(); }
      else if (key === ' ') { el.click(); e.preventDefault(); }
      return;
    }
    /* a strip chip or the More button */
    el = t.closest('.nav-chip, .nav-more-btn');
    if (el && el.parentNode) {
      items = Array.prototype.slice.call(el.parentNode.children).filter(function (c) { return c.classList.contains('nav-chip') || c.classList.contains('nav-more-btn'); });
      i = items.indexOf(el);
      var to = null;
      if (key === 'ArrowRight') to = items[(i + 1) % items.length];
      else if (key === 'ArrowLeft') to = items[(i - 1 + items.length) % items.length];
      else if (key === 'Home') to = items[0];
      else if (key === 'End') to = items[items.length - 1];
      if (to) { items.forEach(function (c) { c.tabIndex = -1; }); to.tabIndex = 0; to.focus(); e.preventDefault(); return; }
      if (el.classList.contains('nav-more-btn') && (key === 'ArrowDown' || key === 'Enter' || key === ' ')) {
        dhNavMoreToggle(el, el.parentNode.querySelector('.nav-more-menu'), true); e.preventDefault(); return;
      }
      if (key === ' ' && el.classList.contains('nav-chip')) { el.click(); e.preventDefault(); }
      return;
    }
    /* the sidebar rail */
    el = t.closest('.nav-rail-item');
    if (el) {
      items = Array.prototype.slice.call(document.querySelectorAll('#nav-sidebar .nav-rail-item')); i = items.indexOf(el);
      var go = null, pth = [];
      try { pth = JSON.parse(el.dataset.navto || '[]'); } catch (x) {}
      if (key === 'ArrowDown') go = items[Math.min(i + 1, items.length - 1)];
      else if (key === 'ArrowUp') go = items[Math.max(i - 1, 0)];
      else if (key === 'Home') go = items[0];
      else if (key === 'End') go = items[items.length - 1];
      else if (key === 'ArrowRight' && el.getAttribute('aria-expanded') === 'false') { el.click(); e.preventDefault(); return; }
      else if (key === 'ArrowRight' && el.getAttribute('aria-expanded') === 'true') go = items[i + 1];
      else if (key === 'ArrowLeft' && pth.length > 1) {
        var want = JSON.stringify(pth.slice(0, -1));
        go = items.filter(function (x) { return x.dataset.navto === want; })[0];
      }
      else if (key === ' ') { el.click(); e.preventDefault(); return; }
      if (go) { items.forEach(function (x) { x.tabIndex = -1; }); go.tabIndex = 0; go.focus(); e.preventDefault(); }
    }
  }
  /* ── Init ────────────────────────────────────────────────────────── */
  function initNav() {
    var tree = buildNavTree();

    /* Restore from the URL, validating segment by segment and truncating at the
       first miss, so a renamed node degrades to its parent instead of a blank
       page. Old 1.x {group,panel} hashes are shimmed to a path. */
    var saved = (typeof URLState !== 'undefined' && URLState.load) ? URLState.load() : {};
    var wanted = [];
    if (saved && saved.nav && saved.nav.length) {
      wanted = saved.nav;
    } else if (saved && saved.group) {
      wanted = [saved.group]; /* 1.x hash shim */
      if (saved.panel) wanted.push(saved.panel);
    }

    var valid = [];
    var node = tree;
    for (var i = 0; i < wanted.length; i++) {
      if (!node.children[wanted[i]]) break;
      node = node.children[wanted[i]];
      valid.push(wanted[i]);
    }

    applyNav(navAutoDescend(tree, valid));
    initTableCollapsibles();

    /* keyboard: arrows, Home, End, Space in the menu; the More menu closes on an outside click */
    document.addEventListener('keydown', dhNavKeydown);
    document.addEventListener('click', function (e) {
      if (e.target.closest && e.target.closest('.nav-more')) return;
      document.querySelectorAll('.nav-more-menu:not([hidden])').forEach(function (m) { dhNavMoreToggle(m.parentNode.querySelector('.nav-more-btn'), m, false); });
    });
    /* re-fit the strips when the width changes (debounced; only the width matters) */
    var navLastW = window.innerWidth, navFitTimer = null;
    window.addEventListener('resize', function () {
      if (window.innerWidth === navLastW) return;
      navLastW = window.innerWidth;
      clearTimeout(navFitTimer);
      navFitTimer = setTimeout(function () { renderNavMenu(currentNavPath); updateNavBadges(); }, 150);
    });

    /* Compat surface for anything that called into the 1.x internals. */
    window._applyNav = applyNav;
    window._navPath = function () { return currentNavPath.slice(); };
    window._showPanel = function (id) {
      var sec = document.getElementById('section-' + id) || document.getElementById('bsection-' + id);
      if (sec) applyNav(navPathOf(sec));
    };
    window._showGroup = function (g) { applyNav(navAutoDescend(buildNavTree(), [g])); };
    window._showSubGroup = function (g, sg) { applyNav(navAutoDescend(buildNavTree(), sg ? [g, sg] : [g])); };
  }

  /* Update nav row-count badge — shows filtered/total e.g. "12/47" when filtered */
  function updateNavBadge(id) {
    var badge = document.querySelector('.nav-badge[data-table="'+id+'"]');
    if (!badge || !engines[id]) return;
    var filtered = engines[id]._getFiltered().length;
    var total = engines[id].allData.length;
    badge.textContent = (filtered < total) ? filtered+'/'+total : total;
  }

  /* v2.4.0 - New-DhDashboard -NavBadge: a pill per menu item with the worst finding
     severity at or below it. The index is built once (findings are static) from the
     findings blocks that have a menu path: counts roll up to every ancestor. */
  var navSevIndex = null;
  function buildNavSevIndex() {
    var idx = {};
    (typeof BLOCKS_CONFIG !== 'undefined' ? BLOCKS_CONFIG : []).forEach(function (b) {
      if (b.blockType !== 'findings') return;
      var el = document.getElementById('block-' + b.id);
      var sec = el && el.closest('[data-navpath]');
      if (!sec) return;
      var p = navPathOf(sec);
      if (!p.length) return;
      var c = { critical: 0, warning: 0, info: 0 };
      (b.findings || []).forEach(function (f) { if (c[f.severity] !== undefined) c[f.severity]++; });
      if (!c.critical && !c.warning && !c.info) return;
      for (var i = 1; i <= p.length; i++) {
        var key = JSON.stringify(p.slice(0, i));
        var e = idx[key] || (idx[key] = { critical: 0, warning: 0, info: 0 });
        e.critical += c.critical; e.warning += c.warning; e.info += c.info;
      }
    });
    return idx;
  }
  function updateNavSeverity() {
    var nav = document.getElementById('report-nav');
    if (!nav || nav.dataset.navbadge !== 'true') return;
    if (!navSevIndex) navSevIndex = buildNavSevIndex();
    document.querySelectorAll('[data-navto]').forEach(function (el) {
      var old = el.querySelector('.nav-sev');
      if (old) old.remove();
      var e = navSevIndex[el.dataset.navto];
      if (!e) return;
      var worst = e.critical ? 'critical' : (e.warning ? 'warning' : 'info');
      var parts = [];
      if (e.critical) parts.push(e.critical + ' critical');
      if (e.warning) parts.push(e.warning + (e.warning === 1 ? ' warning' : ' warnings'));
      if (e.info) parts.push(e.info + ' info');
      var pill = document.createElement('span');
      pill.className = 'nav-sev nav-sev-' + worst;
      pill.title = parts.join(', ');
      pill.textContent = String(e[worst]);
      var sr = document.createElement('span');
      sr.className = 'nav-sev-sr';
      sr.textContent = ' (' + parts.join(', ') + ')';
      pill.appendChild(sr);
      el.appendChild(pill);
    });
  }

  function updateNavBadges() {
    Object.keys(engines).forEach(function (id) { updateNavBadge(id); });
    updateNavSeverity();
  }

'@

}