Private/Get-DhJsCore.ps1

function Get-DhJsCore {
    # Auto-split fragment of the dashboard runtime JS (see Get-DhJsContent).
    return @'
(function () {
  'use strict';

  /* =========================================================================
     INJECTED CONFIG
     ========================================================================= */
  var TABLES_CONFIG = /*%%TABLES_CONFIG%%*/[];
  var SUMMARY_CONFIG = /*%%SUMMARY_CONFIG%%*/[];
  var BLOCKS_CONFIG = /*%%BLOCKS_CONFIG%%*/[];
  var ALERTS_CONFIG = /*%%ALERTS_CONFIG%%*/[];
  var GLOBALFILTER_CONFIG = /*%%GLOBALFILTER_CONFIG%%*/[];

  /* =========================================================================
     GLOBAL STATE
     ========================================================================= */
  var engines = {}; /* tableId -> TableEngine */
  var cardFilters = {}; /* filterId -> { field, values[] } */
  var currentTheme = 'default'; /* 'default' or 'light' */
  var tableDensity = 'normal'; /* 'compact' | 'normal' | 'comfortable' */
  var currentGroup = ''; /* active nav group in two-tier mode */
  var currentSubGroup = ''; /* active nav sub-group in three-tier mode */
  var dismissedAlerts = {}; /* alertId -> true; persisted in URL hash */

  /* esc() — HTML-encode a value before inserting into innerHTML.
     Prevents XSS when data originates from external sources (Azure tags, resource names, etc.) */
  function esc(s) {
    if (s == null) return '';
    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  /* =========================================================================
     GLOBAL FILTER BUS (v1.9.0) — page-level facet / date-range filters. Every
     table subscribes; a table only honours a filter whose field it actually
     has (tables lacking the field are unaffected). Charts derived from tables
     re-aggregate automatically because their source table re-renders.
     ========================================================================= */
  var GlobalFilter = {
    _subs: [],
    facets: {}, /* field -> selected value ('' = all) */
    ranges: {}, /* field -> { from, to } as ISO date strings */
    subscribe: function (fn) { this._subs.push(fn); },
    _notify: function () {
      this._subs.forEach(function (fn) { try { fn(); } catch (e) {} });
      if (typeof URLState !== 'undefined') URLState.save();
    },
    setFacet: function (field, value) { this.facets[field] = value || ''; this._notify(); },
    setRange: function (field, from, to) { this.ranges[field] = { from: from || '', to: to || '' }; this._notify(); },
    clear: function () {
      var self = this;
      Object.keys(this.facets).forEach(function (f) { self.facets[f] = ''; });
      Object.keys(this.ranges).forEach(function (f) { self.ranges[f] = { from: '', to: '' }; });
      this._notify();
      renderGlobalFilters(); /* reset the controls to their empty state */
    },
    active: function () {
      var any = false, self = this;
      Object.keys(this.facets).forEach(function (f) { if (self.facets[f]) any = true; });
      Object.keys(this.ranges).forEach(function (f) { var r = self.ranges[f]; if (r && (r.from || r.to)) any = true; });
      return any;
    },
    /* true if the row passes every active filter that applies to it */
    matchRow: function (row) {
      var self = this, ok = true;
      Object.keys(this.facets).forEach(function (f) {
        var v = self.facets[f];
        if (!v || !(f in row)) return;
        if (String(row[f]) !== v) ok = false;
      });
      Object.keys(this.ranges).forEach(function (f) {
        var r = self.ranges[f];
        if (!r || (!r.from && !r.to) || !(f in row)) return;
        var t = Date.parse(row[f]);
        if (isNaN(t)) return; /* unparseable → don't exclude */
        if (r.from) { var tf = Date.parse(r.from); if (!isNaN(tf) && t < tf) ok = false; }
        if (r.to) { var tt = Date.parse(r.to); if (!isNaN(tt) && t > tt + 86399999) ok = false; } /* inclusive end-of-day */
      });
      return ok;
    }
  };

  /* Distinct non-empty values of a field across every table's data. */
  function _gfDistinct(field) {
    var seen = {}, out = [];
    TABLES_CONFIG.forEach(function (t) {
      (t.data || []).forEach(function (row) {
        if (!(field in row)) return;
        var v = row[field];
        if (v === null || v === undefined || v === '') return;
        var s = String(v);
        if (!seen[s]) { seen[s] = true; out.push(s); }
      });
    });
    out.sort(function (a, b) { var an = parseFloat(a), bn = parseFloat(b); return (!isNaN(an) && !isNaN(bn)) ? (an - bn) : a.localeCompare(b); });
    return out;
  }

  function renderGlobalFilters() {
    var bar = document.getElementById('global-filter-bar');
    if (!bar) return;
    if (!GLOBALFILTER_CONFIG || !GLOBALFILTER_CONFIG.length) { bar.style.display = 'none'; return; }
    bar.innerHTML = '';

    GLOBALFILTER_CONFIG.forEach(function (gf) {
      var wrap = document.createElement('div'); wrap.className = 'gf-control';
      var ctrlId = 'gf-' + String(gf.field).replace(/[^A-Za-z0-9_-]/g, '-');
      var lbl = document.createElement('label'); lbl.className = 'gf-label';
      lbl.textContent = gf.label || gf.field; lbl.setAttribute('for', ctrlId);
      wrap.appendChild(lbl);

      if (gf.type === 'daterange') {
        var from = document.createElement('input'); from.type = 'date'; from.className = 'gf-date'; from.id = ctrlId + '-from';
        var to = document.createElement('input'); to.type = 'date'; to.className = 'gf-date'; to.id = ctrlId + '-to';
        var cur = GlobalFilter.ranges[gf.field] || { from: '', to: '' };
        from.value = cur.from; to.value = cur.to;
        var upd = function () { GlobalFilter.setRange(gf.field, from.value, to.value); };
        from.addEventListener('change', upd); to.addEventListener('change', upd);
        from.setAttribute('aria-label', (gf.label || gf.field) + ' from');
        to.setAttribute('aria-label', (gf.label || gf.field) + ' to');
        var sep = document.createElement('span'); sep.className = 'gf-date-sep'; sep.textContent = '→';
        wrap.appendChild(from); wrap.appendChild(sep); wrap.appendChild(to);
      } else {
        var sel = document.createElement('select'); sel.className = 'gf-select'; sel.id = ctrlId;
        var values = (gf.values && gf.values.length) ? gf.values.map(String) : _gfDistinct(gf.field);
        var optAll = document.createElement('option'); optAll.value = '';
        optAll.textContent = gf.placeholder || ('All ' + (gf.label || gf.field));
        sel.appendChild(optAll);
        values.forEach(function (v) { var o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); });
        sel.value = GlobalFilter.facets[gf.field] || '';
        sel.addEventListener('change', function () { GlobalFilter.setFacet(gf.field, sel.value); });
        wrap.appendChild(sel);
      }
      bar.appendChild(wrap);
    });

    var clr = document.createElement('button');
    clr.type = 'button'; clr.className = 'gf-clear'; clr.textContent = 'Clear filters';
    clr.addEventListener('click', function () { GlobalFilter.clear(); });
    bar.appendChild(clr);
    bar.style.display = GlobalFilter.active() || GLOBALFILTER_CONFIG.length ? '' : 'none';
  }

  /* =========================================================================
     URL STATE — encode active panel + card filters + text filters in hash
     ========================================================================= */
  var URLState = {
    _debounce: null,
    save: function () {
      clearTimeout(this._debounce);
      var self = this;
      this._debounce = setTimeout(function () { self._write(); }, 300);
    },
    _write: function () {
      try {
        var state = {};
        /* active group (two-tier mode) */
        if (currentGroup) state.group = currentGroup;
        /* active panel */
        var activeLink = document.querySelector('.nav-link.nav-active');
        if (activeLink) state.panel = activeLink.dataset.table || activeLink.dataset.panel;
        /* text filters per table */
        var filters = {};
        Object.keys(engines).forEach(function (id) {
          if (engines[id].filterText) filters[id] = engines[id].filterText;
        });
        if (Object.keys(filters).length) state.filters = filters;
        /* card filters */
        if (Object.keys(cardFilters).length) state.cardFilters = cardFilters;
        /* density */
        if (tableDensity !== 'normal') state.density = tableDensity;
        /* dismissed alert banners — array of ids */
        var dismissed = Object.keys(dismissedAlerts);
        if (dismissed.length) state.alerts = dismissed;
        /* global filters (v1.9.0) */
        if (typeof GlobalFilter !== 'undefined' && GlobalFilter.active()) {
          var gf = {};
          Object.keys(GlobalFilter.facets).forEach(function (f) { if (GlobalFilter.facets[f]) { gf.facets = gf.facets || {}; gf.facets[f] = GlobalFilter.facets[f]; } });
          Object.keys(GlobalFilter.ranges).forEach(function (f) { var r = GlobalFilter.ranges[f]; if (r && (r.from || r.to)) { gf.ranges = gf.ranges || {}; gf.ranges[f] = r; } });
          state.gf = gf;
        }
        history.replaceState(null, '', '#' + encodeURIComponent(JSON.stringify(state)));
      } catch(e) {}
    },
    load: function () {
      try {
        var raw = decodeURIComponent((location.hash || '').replace('#',''));
        if (!raw || raw[0] !== '{') return {};
        return JSON.parse(raw);
      } catch(e) { return {}; }
    }
  };

  /* =========================================================================
     FORMATTING ENGINE
     ========================================================================= */
  var FMT = {
    apply: function (col, rawVal) {
      if (rawVal === null || rawVal === undefined || rawVal === '') return '';
      var str = String(rawVal);
      var fmt = (col.format || '').toLowerCase();
      if (!fmt || fmt === 'text') return str;
      var locale = col.locale || undefined;
      var decimals = (col.decimals !== undefined && col.decimals >= 0) ? col.decimals : undefined;
      switch (fmt) {
        case 'number': {
          var n = parseFloat(str); if (isNaN(n)) return str;
          var o = decimals !== undefined ? {minimumFractionDigits:decimals,maximumFractionDigits:decimals} : {};
          return new Intl.NumberFormat(locale, o).format(n);
        }
        case 'currency': {
          var n = parseFloat(str); if (isNaN(n)) return str;
          return new Intl.NumberFormat(locale, {style:'currency',currency:col.currency||'EUR',
            minimumFractionDigits:decimals!==undefined?decimals:2,maximumFractionDigits:decimals!==undefined?decimals:2}).format(n);
        }
        case 'bytes': {
          var n = parseFloat(str); if (isNaN(n)) return str;
          var u=['B','KB','MB','GB','TB','PB'], i=0;
          while (n>=1024&&i<u.length-1){n/=1024;i++;}
          var dp=decimals!==undefined?decimals:(i===0?0:2);
          return new Intl.NumberFormat(locale,{minimumFractionDigits:dp,maximumFractionDigits:dp}).format(n)+' '+u[i];
        }
        case 'percent': {
          var n = parseFloat(str); if (isNaN(n)) return str;
          var pct = n>1?n:n*100, dp=decimals!==undefined?decimals:2;
          return new Intl.NumberFormat(locale,{minimumFractionDigits:dp,maximumFractionDigits:dp}).format(pct)+'\u00A0%';
        }
        case 'datetime': {
          var d = new Date(str); if (isNaN(d.getTime())) return str;
          var p=col.datePattern||'';
          if (p) {
            var pad=function(v,l){return String(v).padStart(l||2,'0');};
            return p.replace('dd',pad(d.getDate())).replace('MM',pad(d.getMonth()+1))
              .replace('yyyy',d.getFullYear()).replace('yy',String(d.getFullYear()).slice(-2))
              .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
          }
          return new Intl.DateTimeFormat(locale).format(d);
        }
        case 'duration': {
          var s=parseInt(str,10); if(isNaN(s)) return str;
          var h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60,parts=[];
          if(h) parts.push(h+'h');
          if(m||h) parts.push(String(m).padStart(2,'0')+'m');
          parts.push(String(sec).padStart(2,'0')+'s');
          return parts.join(' ');
        }
        default: return str;
      }
    },
    forExport: function (col, rawVal) {
      if (rawVal===null||rawVal===undefined) return '';
      var fmt=(col.format||'').toLowerCase();
      if (!fmt||fmt==='text') return rawVal;
      return FMT.apply(col, rawVal);
    }
  };

  /* =========================================================================
     THRESHOLD ENGINE — numeric (Min/Max) + string (Value)
     ========================================================================= */
  function getThresholdClass(col, rawVal) {
    var isMissing = (rawVal===null||rawVal===undefined||rawVal==='');
    /* v1.4 F13 — when the column was declared with -Rag NoData=$true, paint
       missing/null cells with cell-nodata. Wins over any numeric thresholds
       that would otherwise return ''. */
    if (isMissing && col.ragNoData) return 'cell-nodata';
    if (!col.thresholds||!col.thresholds.length) return '';
    var str=(rawVal!==null&&rawVal!==undefined)?String(rawVal):'';
    var numVal=parseFloat(str);
    for (var i=0;i<col.thresholds.length;i++) {
      var t=col.thresholds[i];
      if (t.value!==undefined&&t.value!==null) {
        if (str.toLowerCase()===String(t.value).toLowerCase()) return t['class']||'';
        continue;
      }
      if (isNaN(numVal)) continue;
      var ok=(t.min===undefined||t.min===null||numVal>=t.min)&&
             (t.max===undefined||t.max===null||numVal<t.max);
      if (ok) return t['class']||'';
    }
    return '';
  }

  /* =========================================================================
     CSS VARIABLE HELPER
     ========================================================================= */
  function cssVar(name, fallback) {
    var v=getComputedStyle(document.documentElement).getPropertyValue(name).trim();
    return v||fallback;
  }

  /* =========================================================================
     THEME TOGGLE
     Two modes:
       1. Dual-embed: both <style> tags present (data-theme attrs) — swap disabled
       2. Single CSS fallback: toggle CSS variable overrides via body class
     ========================================================================= */
  function initThemeToggle() {
    var btn = document.getElementById('btn-theme-toggle');
    if (!btn) return;

    var primaryStyle = document.getElementById('theme-primary');
    var alternateStyle = document.getElementById('theme-alternate');
    var isDual = primaryStyle && alternateStyle;

    /* ── Dual-embed mode ────────────────────────────────────────────── */
    if (isDual) {
      var primaryName = primaryStyle.dataset.theme || 'Theme A';
      var alternateName = alternateStyle.dataset.theme || 'Theme B';
      var usingPrimary = true;

      /* Initial button label */
      btn.textContent = '⇄ ' + alternateName;
      btn.title = 'Switch to ' + alternateName;

      btn.addEventListener('click', function () {
        usingPrimary = !usingPrimary;
        if (usingPrimary) {
          primaryStyle.media = ''; /* activate primary */
          alternateStyle.media = 'none'; /* suppress alternate */
          btn.textContent = '⇄ ' + alternateName;
          btn.title = 'Switch to ' + alternateName;
        } else {
          alternateStyle.media = ''; /* activate alternate */
          primaryStyle.media = 'none'; /* suppress primary */
          btn.textContent = '⇄ ' + primaryName;
          btn.title = 'Switch to ' + primaryName;
        }
        URLState.save();
      });
      return;
    }

    /* ── Single-CSS fallback mode (basic variable overrides) ────────── */
    var lightActive = false;
    btn.textContent = '☀ Light';
    btn.title = 'Switch to light mode';
    btn.addEventListener('click', function () {
      lightActive = !lightActive;
      if (lightActive) {
        document.body.classList.add('theme-forced-light');
        document.body.classList.remove('theme-forced-dark');
        btn.textContent = '🌙 Dark';
        btn.title = 'Switch to dark mode';
      } else {
        document.body.classList.add('theme-forced-dark');
        document.body.classList.remove('theme-forced-light');
        btn.textContent = '☀ Light';
        btn.title = 'Switch to light mode';
      }
    });
  }

  /* =========================================================================
     DENSITY TOGGLE
     ========================================================================= */
  function initDensityToggle() {
    var btn = document.getElementById('btn-density-toggle');
    if (!btn) return;
    var states = ['normal','compact','comfortable'];
    var labels = { normal:'⊞ Normal', compact:'⊟ Compact', comfortable:'⊠ Spacious' };
    var i = 0;
    btn.textContent = labels['normal'];
    btn.addEventListener('click', function () {
      document.body.classList.remove('density-'+states[i]);
      i = (i+1) % states.length;
      tableDensity = states[i];
      document.body.classList.add('density-'+states[i]);
      btn.textContent = labels[states[i]];
      URLState.save();
    });
  }

  /* =========================================================================
     SPARK — pure-SVG mini-chart helper.
     Used by Add-DhSummary (tile sparkline, F2) and the v1.4 sparkline table
     cell type (F8). No external library; renders a single inline SVG string.

     SPARK.build(series, opts) -> '<svg>...</svg>' string (use innerHTML).
       series numeric array (non-numeric items should already be filtered out)
       opts.style 'line' (default) | 'area' | 'bars'
       opts.width default 80
       opts.height default 24
       opts.min explicit Y-min (else series min)
       opts.max explicit Y-max (else series max)
       opts.color explicit colour (else var(--chart-1) fallback)
     Returns '' for empty series so callers can do a truthy check.
     ========================================================================= */
  /* v1.4 F8 — reduce an array of values to a comparable scalar for sort.
     SortBy: 'last' (default) | 'avg' | 'max' | 'min' | 'sum'. Non-array
     values pass through unchanged so legacy data still sorts correctly. */
  function sparkReduce(arr, mode) {
    if (!Array.isArray(arr)) return arr;
    var nums = [];
    for (var i = 0; i < arr.length; i++) {
      var n = Number(arr[i]);
      if (isFinite(n)) nums.push(n);
    }
    if (!nums.length) return 0;
    switch ((mode || 'last').toLowerCase()) {
      case 'avg': return nums.reduce(function(s,v){return s+v;}, 0) / nums.length;
      case 'max': return Math.max.apply(null, nums);
      case 'min': return Math.min.apply(null, nums);
      case 'sum': return nums.reduce(function(s,v){return s+v;}, 0);
      case 'last':
      default: return nums[nums.length - 1];
    }
  }

  var SPARK = {
    build: function (series, opts) {
      opts = opts || {};
      if (!series || !series.length) return '';
      var n = series.length;
      var nums = [];
      for (var i = 0; i < n; i++) {
        var v = Number(series[i]);
        nums.push(isFinite(v) ? v : 0);
      }
      var W = opts.width || 80;
      var H = opts.height || 24;
      var style = (opts.style || 'line').toLowerCase();
      var color = opts.color || cssVar('--chart-1', '#00c8ff');
      var min = (opts.min != null) ? Number(opts.min) : Math.min.apply(null, nums);
      var max = (opts.max != null) ? Number(opts.max) : Math.max.apply(null, nums);
      if (min === max) { min -= 0.5; max += 0.5; } /* flat series: still draw at mid */
      var pad = 1;
      var xAt = function (i) {
        if (n === 1) return W / 2;
        return pad + (i / (n - 1)) * (W - 2 * pad);
      };
      var yAt = function (v) {
        return (H - pad) - ((v - min) / (max - min)) * (H - 2 * pad);
      };
      var inner = '';
      if (style === 'bars') {
        var bw = Math.max(1, (W / n) - 1);
        var baseline = H - pad;
        for (var i = 0; i < n; i++) {
          var x = (i / n) * W + 0.5;
          var y = yAt(nums[i]);
          var h = baseline - y;
          if (h < 0.5) h = 0.5; /* visible "zero" bar */
          inner += '<rect x="' + x.toFixed(2) + '" y="' + y.toFixed(2) +
                   '" width="' + bw.toFixed(2) + '" height="' + h.toFixed(2) +
                   '" fill="' + color + '"/>';
        }
      } else {
        var pts = [];
        for (var i = 0; i < n; i++) pts.push(xAt(i).toFixed(2) + ',' + yAt(nums[i]).toFixed(2));
        var ptsStr = pts.join(' ');
        if (style === 'area') {
          var firstX = xAt(0).toFixed(2);
          var lastX = xAt(n - 1).toFixed(2);
          var areaPts = pts.map(function (p) { return 'L' + p; }).join(' ');
          var areaPath = 'M' + firstX + ',' + (H - pad) + ' ' + areaPts + ' L' + lastX + ',' + (H - pad) + ' Z';
          inner = '<path d="' + areaPath + '" fill="' + color + '" fill-opacity="0.22" stroke="none"/>' +
                  '<polyline points="' + ptsStr + '" fill="none" stroke="' + color +
                  '" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>';
        } else {
          /* default: line */
          inner = '<polyline points="' + ptsStr + '" fill="none" stroke="' + color +
                  '" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>';
        }
      }
      return '<svg class="spark-svg" width="' + W + '" height="' + H +
             '" viewBox="0 0 ' + W + ' ' + H + '" aria-hidden="true">' + inner + '</svg>';
    }
  };

  /* =========================================================================
     DELTA — trend & delta indicator helper (Add-DhSummary tile F1)
     Returns either '' (no indicator wanted) or an object
       { dir: 'up'|'down'|'flat', className: 'delta-good'|'delta-bad'|'delta-flat', html: '<arrow> +12 (+5.5%)' }
     Caller wraps the html in <div class="summary-delta delta-<dir> <className>">
     ========================================================================= */
  var DELTA = {
    build: function (item) {
      var curr = Number(item.value);
      var prev = (item.previous != null) ? Number(item.previous) : null;
      var hasPrev = (prev !== null) && isFinite(prev) && isFinite(curr);

      /* Direction: explicit Trend wins; otherwise derive from Previous vs Value */
      var dir = (item.trend && item.trend !== 'auto') ? item.trend : null;
      if (!dir) {
        if (!hasPrev) return ''; /* no signal at all */
        var d0 = curr - prev;
        dir = (d0 > 0) ? 'up' : (d0 < 0 ? 'down' : 'flat');
      }

      /* Compute delta numbers when Previous is supplied */
      var deltaAbs = hasPrev ? (curr - prev) : null;
      var deltaPct = (hasPrev && prev !== 0) ? (deltaAbs / Math.abs(prev)) * 100 : null;

      /* Colour: explicit TrendIsGood overrides; otherwise semantic (up=good, down=bad, flat=neutral) */
      var className;
      if (item.trendIsGood === true) className = 'delta-good';
      else if (item.trendIsGood === false) className = 'delta-bad';
      else {
        className = (dir === 'up') ? 'delta-good' : (dir === 'down' ? 'delta-bad' : 'delta-flat');
      }

      /* Arrow glyph */
      var arrow = (dir === 'up') ? '▲' : (dir === 'down' ? '▼' : '▶');

      /* Format the textual portion per item.deltaFormat */
      var fmt = item.deltaFormat || 'both';
      function fmtAbs(n) {
        if (n === 0) return '±0'; /* ±0 */
        var sign = n > 0 ? '+' : ''; /* '-' is part of the number itself */
        /* Preserve original precision: integer in / integer out; floats use toFixed(decimals||2) */
        var dec = (item.decimals != null && item.decimals >= 0) ? item.decimals : null;
        var body = (dec !== null) ? n.toFixed(dec) : String(n);
        return sign + body;
      }
      function fmtPct(p) {
        var sign = p > 0 ? '+' : '';
        return sign + p.toFixed(1) + '%';
      }

      var parts = [];
      if (deltaAbs !== null && (fmt === 'absolute' || fmt === 'both')) {
        parts.push(fmtAbs(deltaAbs));
      }
      if (deltaPct !== null && (fmt === 'percent' || fmt === 'both')) {
        parts.push(fmt === 'both' ? '(' + fmtPct(deltaPct) + ')' : fmtPct(deltaPct));
      }
      var labelTxt = parts.join(' ');

      return {
        dir: dir,
        className: className,
        html: '<span class="delta-arrow">' + arrow + '</span>' +
              (labelTxt ? ' <span class="delta-text">' + esc(labelTxt) + '</span>' : '')
      };
    }
  };

  /* =========================================================================
     ALERT BANNERS — top-of-page strips above the summary
     ========================================================================= */
  function renderAlerts() {
    if (!ALERTS_CONFIG || !ALERTS_CONFIG.length) return;
    var container = document.getElementById('report-alerts');
    if (!container) return;
    container.innerHTML = '';

    /* Restore dismissed-state from URL hash so reloads keep banners hidden */
    var saved = URLState.load();
    if (saved.alerts && saved.alerts.length) {
      saved.alerts.forEach(function (id) { dismissedAlerts[id] = true; });
    }

    ALERTS_CONFIG.forEach(function (alert) {
      if (dismissedAlerts[alert.id]) return; /* user dismissed earlier, keep hidden */

      var severityClass = 'alert-' + (alert.severity || 'info');
      var banner = document.createElement('div');
      banner.className = 'alert-banner ' + severityClass;
      banner.setAttribute('data-alert-id', alert.id);
      banner.setAttribute('role', 'alert');
      /* v1.4.2+ — NavGroup binding. The general nav-routing show/hide pass
         picks up [data-navgroup] elements anywhere in the body. */
      if (alert.navGroup) banner.setAttribute('data-navgroup', alert.navGroup);
      if (alert.navSubGroup) banner.setAttribute('data-navsubgroup', alert.navSubGroup);

      var iconHtml = alert.icon
        ? '<span class="alert-icon">' + esc(alert.icon) + '</span>'
        : '';
      var actionHtml = '';
      if (alert.action && alert.action.label) {
        actionHtml = '<button class="alert-action-btn" type="button">'
                   + esc(alert.action.label) + '</button>';
      }
      var dismissHtml = alert.dismissible
        ? '<button class="alert-dismiss-btn" type="button" aria-label="Dismiss alert" title="Dismiss">&times;</button>'
        : '';

      /* alert.message may contain inline HTML by design (matches Add-DhHtmlBlock policy) */
      var bodyHtml =
        '<div class="alert-body">'
        + iconHtml
        + '<span class="alert-message">' + alert.message + '</span>'
        + '</div>';
      var actionsHtml = '<div class="alert-actions">' + actionHtml + dismissHtml + '</div>';

      if (alert.collapsible) {
        /* v1.5.1 — wrap the message body in a collapsible header bar.
           The action + dismiss buttons stay outside the collapse so they
           remain reachable when the body is folded away. */
        var isOpen = alert.defaultOpen !== false;
        var headTxt = alert.title || '';
        var uidA = alert.id;
        banner.classList.add('alert-banner-collapsible');
        banner.innerHTML =
          '<div class="alert-collapsible-head">'
          + '<button class="alert-toggle' + (isOpen?' open':'') + '" type="button" '
          + 'id="atoggle-' + uidA + '" aria-expanded="' + (isOpen?'true':'false') + '">'
          + iconHtml
          + '<span class="alert-title">' + esc(headTxt) + '</span>'
          + '<span class="alert-chevron">' + (isOpen?'▾':'▸') + '</span>'
          + '</button>'
          + actionsHtml
          + '</div>'
          + '<div class="alert-collapsible-body' + (isOpen?' open':'') + '" id="abody-' + uidA + '">'
          + bodyHtml
          + '</div>';
        var aToggle = banner.querySelector('#atoggle-' + uidA);
        var aBody = banner.querySelector('#abody-' + uidA);
        var aChev = banner.querySelector('.alert-chevron');
        aToggle.addEventListener('click', function () {
          var open = aBody.classList.toggle('open');
          aToggle.classList.toggle('open', open);
          aToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
          if (aChev) aChev.textContent = open ? '▾' : '▸';
        });
      } else {
        banner.innerHTML = bodyHtml + actionsHtml;
      }

      /* Wire dismiss */
      if (alert.dismissible) {
        var dismissBtn = banner.querySelector('.alert-dismiss-btn');
        dismissBtn.addEventListener('click', function () {
          dismissedAlerts[alert.id] = true;
          banner.classList.add('alert-dismissed');
          /* Match CSS transition before removal */
          setTimeout(function () { banner.remove(); }, 200);
          URLState.save();
        });
      }

      /* Wire action button */
      if (alert.action && alert.action.label) {
        var actionBtn = banner.querySelector('.alert-action-btn');
        actionBtn.addEventListener('click', function () {
          if (alert.action.url) {
            window.open(alert.action.url, '_blank', 'noopener');
            return;
          }
          if (alert.action.tableId && window._showPanel) {
            window._showPanel(alert.action.tableId);
            /* Apply filter text if requested */
            if (alert.action.filter && engines[alert.action.tableId]) {
              var eng = engines[alert.action.tableId];
              var inp = document.getElementById('filter-' + alert.action.tableId);
              eng.filterText = alert.action.filter;
              if (inp) inp.value = alert.action.filter;
              eng.currentPage = 1;
              eng.render();
              URLState.save();
            }
          }
        });
      }

      container.appendChild(banner);
    });
  }

  /* =========================================================================
     SUMMARY / TILE HELPERS (module-scope so the bullet, line-chart, and
     summarystrip renderers can all reuse them).
     ========================================================================= */

  /* Threshold matcher (v1.4 F4) — column-style Min/Max bands. Highest
     matching Min wins, supporting the spec-friendly Min-only pattern. */
  function pickThresholdClass(value, thresholds) {
    if (!thresholds || !thresholds.length) return '';
    var best = null, bestMin = -Infinity;
    for (var i = 0; i < thresholds.length; i++) {
      var th = thresholds[i];
      var hasMin = (th.min !== undefined && th.min !== null);
      var hasMax = (th.max !== undefined && th.max !== null);
      if (hasMin && value < th.min) continue;
      if (hasMax && value >= th.max) continue;
      var mv = hasMin ? th.min : -Infinity;
      if (best === null || mv > bestMin) { best = th; bestMin = mv; }
    }
    return best ? (best['class'] || '') : '';
  }

  /* Map a cell-* class to its accent CSS var so SVG strokes can read it. */
  function classToAccent(cls) {
    switch (cls) {
      case 'cell-ok': return 'var(--cell-ok-fg)';
      case 'cell-warn': return 'var(--cell-warn-fg)';
      case 'cell-danger': return 'var(--cell-danger-fg)';
      case 'cell-nodata': return 'var(--cell-nodata-fg)';
      default: return 'var(--accent-primary)';
    }
  }

  /* Background-tint variant — used by the bullet chart for performance bands. */
  function classToAccentBg(cls) {
    switch (cls) {
      case 'cell-ok': return 'var(--cell-ok-bg)';
      case 'cell-warn': return 'var(--cell-warn-bg)';
      case 'cell-danger': return 'var(--cell-danger-bg)';
      case 'cell-nodata': return 'var(--cell-nodata-bg)';
      default: return 'rgba(0,0,0,0.04)';
    }
  }

  /* Tile sub-renderers (delta, sparkline, formatted value).
     These power the standard / bignumber / gauge tiles and run identically
     from the global top-of-page strip AND from each per-NavGroup strip. */
  function tileFormatValue(item) {
    return FMT.apply(
      {format:item.format||'',locale:item.locale||'',decimals:item.decimals,currency:item.currency||''},
      item.value
    ) || String(item.value!==null&&item.value!==undefined?item.value:'');
  }
  function tileDeltaHtml(item) {
    if ((item.previous != null) || (item.trend && item.trend !== 'auto')) {
      var d = DELTA.build(item);
      if (d) return '<div class="summary-delta delta-'+d.dir+' '+d.className+'">'+d.html+'</div>';
    }
    return '';
  }
  function tileSparklineHtml(item) {
    if (item.sparkline && item.sparkline.length) {
      return '<div class="summary-sparkline">' +
             SPARK.build(item.sparkline, {
               style: item.sparklineStyle || 'line',
               min: item.sparklineMin,
               max: item.sparklineMax
             }) +
             '</div>';
    }
    return '';
  }

  /* Standard tile (default since 1.3.x) */
  function buildStandardTile(item) {
    var tile = document.createElement('div');
    tile.className = 'summary-tile'+(item['class']?' '+item['class']:'');
    tile.innerHTML = (item.icon?'<span class="summary-icon">'+esc(item.icon)+'</span>':'')+
      '<span class="summary-value">'+esc(tileFormatValue(item))+'</span>'+
      tileDeltaHtml(item)+
      '<span class="summary-label">'+esc(item.label)+'</span>'+
      (item.subLabel?'<span class="summary-sublabel">'+esc(item.subLabel)+'</span>':'')+
      tileSparklineHtml(item);
    return tile;
  }

  /* Big-number hero tile (v1.4 F3) — full-width, very large value typography. */
  function buildBignumberTile(item) {
    var tile = document.createElement('div');
    tile.className = 'summary-tile tile-bignumber'+(item['class']?' '+item['class']:'');
    tile.innerHTML =
      '<div class="bignumber-head">'+
        (item.icon?'<span class="summary-icon">'+esc(item.icon)+'</span>':'')+
        '<span class="summary-label">'+esc(item.label)+'</span>'+
      '</div>'+
      '<div class="bignumber-value">'+esc(tileFormatValue(item))+'</div>'+
      (item.caption?'<div class="bignumber-caption">'+esc(item.caption)+'</div>':'')+
      tileDeltaHtml(item)+
      tileSparklineHtml(item);
    return tile;
  }

  /* Semicircle gauge tile (v1.4 F4). */
  function buildGaugeTile(item) {
    var tile = document.createElement('div');
    tile.className = 'summary-tile tile-gauge'+(item['class']?' '+item['class']:'');

    var rawVal = Number(item.value);
    var min = (item.min != null) ? Number(item.min) : 0;
    var max = (item.max != null) ? Number(item.max) : 100;
    if (max <= min) max = min + 1;
    var isMissing = (item.value === null || item.value === undefined || item.value === '' || !isFinite(rawVal));
    var ratio = isMissing ? 0 : Math.max(0, Math.min(1, (rawVal - min) / (max - min)));

    var matchClass;
    if (isMissing && item.ragNoData) {
      matchClass = 'cell-nodata';
    } else {
      matchClass = pickThresholdClass(rawVal, item.thresholds);
      if (!matchClass) matchClass = item['class'] || '';
    }
    var fillColor = classToAccent(matchClass);

    var cx = 70, cy = 70, r = 56;
    var angle = -Math.PI + ratio * Math.PI;
    var ex = cx + r * Math.cos(angle);
    var ey = cy + r * Math.sin(angle);
    var bgPath = 'M '+(cx-r)+' '+cy+' A '+r+' '+r+' 0 0 1 '+(cx+r)+' '+cy;
    var fillPath = (ratio > 0)
      ? 'M '+(cx-r)+' '+cy+' A '+r+' '+r+' 0 0 1 '+ex.toFixed(2)+' '+ey.toFixed(2)
      : null;

    var readout = tileFormatValue(item);
    var unitTxt = item.unit ? esc(item.unit) : '';

    var svg =
      '<svg class="gauge-svg" viewBox="0 0 140 80" width="140" height="80" aria-hidden="true">'+
        '<path class="gauge-bg" d="'+bgPath+'" stroke="var(--border-subtle)" stroke-width="10" fill="none" stroke-linecap="round"/>'+
        (fillPath ? '<path class="gauge-fill" d="'+fillPath+'" stroke="'+fillColor+'" stroke-width="10" fill="none" stroke-linecap="round"/>' : '')+
        '<text class="gauge-readout" x="70" y="66" text-anchor="middle" font-family="var(--font-display)" font-size="20" font-weight="700" fill="var(--text-primary)">'+esc(readout)+(unitTxt?'<tspan font-size="12" font-weight="600" fill="var(--text-secondary)">'+unitTxt+'</tspan>':'')+'</text>'+
      '</svg>';

    tile.innerHTML =
      (item.icon?'<span class="summary-icon gauge-icon">'+esc(item.icon)+'</span>':'')+
      svg+
      '<span class="summary-label">'+esc(item.label)+'</span>'+
      (item.subLabel?'<span class="summary-sublabel">'+esc(item.subLabel)+'</span>':'')+
      tileDeltaHtml(item);
    return tile;
  }

  function buildTile(item) {
    var tile;
    switch ((item.style || 'tile').toLowerCase()) {
      case 'bignumber': tile = buildBignumberTile(item); break;
      case 'gauge': tile = buildGaugeTile(item); break;
      default: tile = buildStandardTile(item); break;
    }
    /* v1.4.2 — per-tile Action makes the whole tile clickable.
       Reuses the same nav-and-filter wiring used by Add-DhAlertBanner. */
    if (item.action && (item.action.tableId || item.action.url)) {
      tile.classList.add('tile-clickable');
      if (item.action.label) tile.setAttribute('title', item.action.label);
      tile.setAttribute('role', 'button');
      tile.setAttribute('tabindex', '0');
      var fire = function () {
        if (item.action.url) {
          window.open(item.action.url, '_blank', 'noopener');
          return;
        }
        if (item.action.tableId && window._showPanel) {
          window._showPanel(item.action.tableId);
          if (item.action.filter && engines[item.action.tableId]) {
            var eng = engines[item.action.tableId];
            var inp = document.getElementById('filter-' + item.action.tableId);
            eng.filterText = item.action.filter;
            if (inp) inp.value = item.action.filter;
            eng.currentPage = 1;
            eng.render();
            URLState.save();
          }
        }
      };
      tile.addEventListener('click', fire);
      tile.addEventListener('keydown', function (e) {
        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fire(); }
      });
    }
    return tile;
  }

  /* fillStrip — common workhorse used by both renderSummary (top-of-page)
     and the v1.4.1 summarystrip block (per-NavGroup). Optionally wraps the
     tile flow in the collapsible chrome shared with Add-DhCollapsible. */
  function fillStrip(container, items, opts) {
    opts = opts || {};
    container.innerHTML = '';
    if (!items || !items.length) return;

    if (opts.collapsible) {
      var title = opts.title || 'Summary';
      var icon = opts.icon || '';
      var isOpen = opts.defaultOpen !== false;
      var uid = opts.idSuffix || 'summary';
      container.classList.add('summary-collapsible','collapsible-section');

      var iconHtml = icon ? '<span class="collapsible-icon">'+esc(icon)+'</span> ' : '';
      container.innerHTML =
        '<div class="collapsible-toggle'+(isOpen?' open':'')+'" id="ctoggle-'+uid+'">'+
          '<div class="collapsible-toggle-left">'+
            '<span class="collapsible-title">'+iconHtml+esc(title)+'</span>'+
            '<span class="collapsible-badge">'+items.length+'</span>'+
          '</div>'+
          '<span class="collapsible-chevron">'+(isOpen?'▾':'▸')+'</span>'+
        '</div>'+
        '<div class="collapsible-body'+(isOpen?' open':'')+'" id="cbody-'+uid+'">'+
          '<div class="summary-tile-strip" id="cinner-'+uid+'"></div>'+
        '</div>';

      var toggle = container.querySelector('#ctoggle-'+uid);
      var body = container.querySelector('#cbody-'+uid);
      var chev = container.querySelector('.collapsible-chevron');
      toggle.addEventListener('click', function () {
        var open = body.classList.toggle('open');
        toggle.classList.toggle('open', open);
        chev.textContent = open ? '▾' : '▸';
      });

      var inner = container.querySelector('#cinner-'+uid);
      items.forEach(function (item) { inner.appendChild(buildTile(item)); });
      return;
    }

    /* Flat strip */
    items.forEach(function (item) { container.appendChild(buildTile(item)); });
  }

  /* =========================================================================
     SUMMARY TILES — top-of-page strip (no NavGroup; the global summary).
     ========================================================================= */
  function renderSummary() {
    if (!SUMMARY_CONFIG||!SUMMARY_CONFIG.length) return;
    var container = document.getElementById('report-summary');
    if (!container) return;

    fillStrip(container, SUMMARY_CONFIG, {
      collapsible: container.getAttribute('data-collapsible') === 'true',
      title: container.getAttribute('data-title') || 'Summary',
      icon: container.getAttribute('data-icon') || '',
      defaultOpen: container.getAttribute('data-default-open') !== 'false',
      idSuffix: 'summary'
    });
  }

  /* Per-NavGroup summarystrip block (v1.4.1) — same look as the top strip
     but lives inside the block flow so the nav system can hide/show it. */
  function renderSummaryStrip(container, block) {
    container.className = 'report-summary';
    fillStrip(container, block.items || [], {
      collapsible: !!block.collapsible,
      title: block.title || 'Summary',
      icon: block.icon || '',
      defaultOpen: block.defaultOpen !== false,
      idSuffix: 'summary-' + (block.id || 'group')
    });
  }

'@

}