/*
 * report-renderers.mjs — llm-security playground rapport-renderere + parsers.
 *
 * CANONICAL SOURCE. The same code lives INLINE in
 * playground/llm-security-playground.html as a duplicated copy. The two
 * MUST stay in sync. file:// + ESM `import` is blocked in Chrome and
 * Firefox without flags, so the playground retains an inline copy to
 * remain a single-file file:// distribution. This module is the canonical
 * source for the upcoming session 4 CLI (scripts/render-report.mjs).
 *
 * 45 exported functions + PARSERS routing-map + RENDERERS routing-map +
 * KEY_STATS_CONFIG. See the export-block at the bottom of this file.
 *
 * Renderer API: render*(data, slot) — mutates slot.innerHTML and returns
 * undefined. For Node CLI use, pass slot = { innerHTML: '' } and read
 * slot.innerHTML after the call. This matches the playground exactly
 * (bit-identical output by construction; identical code lives in both).
 */


// ============================================================
// ESCAPE HELPERS
// ============================================================
function escapeHtml(str) {
  if (str == null) return '';
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}
function escapeAttr(str) { return escapeHtml(str); }

// ============================================================
// PAGE-SHELL HELPERS (DS Tier 3)
// ============================================================
function renderVerdictPill(verdict, sub) {
  const v = String(verdict || 'n-a').toLowerCase();
  const labels = {
    'go': 'GO',
    'go-with-conditions': 'BETINGET',
    'block': 'BLOKKERT',
    'approved': 'GODKJENT',
    'failed': 'UNDERKJENT',
    'allow': 'TILLATT',
    'warning': 'ADVARSEL',
    'n-a': 'IKKE VURDERT'
  };
  const dsVerdict = (
    v === 'failed' ? 'block' :
    v === 'go-with-conditions' ? 'warning' :
    v === 'go' || v === 'approved' ? 'allow' :
    v
  );
  const subHtml = sub
    ? '<span class="verdict-pill-lg__sub">' + escapeHtml(String(sub)) + '</span>'
    : '';
  return (
    '<div class="verdict-pill-lg" data-verdict="' + escapeAttr(dsVerdict) + '">' +
      '<span class="verdict-pill-lg__verdict">' + escapeHtml(labels[v] || v.toUpperCase()) + '</span>' +
      subHtml +
    '</div>'
  );
}

function renderKeyStatsGrid(stats) {
  if (!stats || !stats.length) return '';
  const items = stats.map(function (s) {
    const cls = 'key-stat' + (s.modifier ? ' key-stat--' + escapeAttr(s.modifier) : '');
    const hint = s.hint ? '<span class="key-stat__hint">' + escapeHtml(s.hint) + '</span>' : '';
    return '<div class="' + cls + '">' +
             '<span class="key-stat__label">' + escapeHtml(s.label || '') + '</span>' +
             '<span class="key-stat__value">' + escapeHtml(String(s.value)) + '</span>' +
             hint +
           '</div>';
  }).join('');
  return '<div class="key-stats">' + items + '</div>';
}

/**
 * Render the page-shell — the DS Tier 3 page__header cluster used on all 4 surfaces:
 *   - onboarding: page__eyebrow="ONBOARDING · n av 5 grupper komplette"
 *   - home: page__eyebrow="HJEM" (m/ hero-modifier for editorial type-hierarki)
 *   - catalog: page__eyebrow="KATALOG"
 *   - project: page__eyebrow="PROSJEKT · <TARGET>"
 * Pluss alle 18 rapport-renderere (eyebrow per archetype).
 * Verdict-rendering via renderVerdictPill — produserer DS verdict-pill-lg.
 * opts: { eyebrow, title, lede, meta:[], verdict, verdictSub, hero, keyStats }
 */
function renderPageShell(opts, bodyHtml) {
  opts = opts || {};
  const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
  const title = '<h1 class="page__title">' + escapeHtml(opts.title || '') + '</h1>';
  const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
  const meta = (opts.meta && opts.meta.length)
    ? '<div class="page__meta">' + opts.meta.map(function (m) { return '<span>' + escapeHtml(m) + '</span>'; }).join('') + '</div>'
    : '';
  const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict, opts.verdictSub) : '';
  const aside = verdict ? '<div class="page__header-aside">' + verdict + '</div>' : '';
  const stats = renderKeyStatsGrid(opts.keyStats);
  const heroClass = opts.hero ? ' page__header--hero' : '';
  return (
    '<header class="page__header' + heroClass + '">' +
      '<div class="page__header-main">' + eyebrow + title + lede + meta + '</div>' +
      aside +
    '</header>' +
    stats +
    (bodyHtml || '')
  );
}

// ============================================================
// INFER VERDICT + KEY-STATS PER ARCHETYPE
// ============================================================
function normalizeVerdict(v) {
  const s = String(v || '').toLowerCase().trim();
  const map = {
    'block': 'block', 'blokk': 'block', 'blokkert': 'block', 'failed': 'failed', 'underkjent': 'failed',
    'warning': 'warning', 'advarsel': 'warning',
    'go-with-conditions': 'go-with-conditions', 'betinget': 'go-with-conditions', 'conditional': 'go-with-conditions',
    'go': 'go', 'tillatt': 'allow', 'allow': 'allow', 'approved': 'approved', 'godkjent': 'approved',
    'n-a': 'n-a', 'na': 'n-a', 'ikke-vurdert': 'n-a'
  };
  return map[s] || s || 'n-a';
}

function inferVerdict(data, archetype) {
  if (!data) return 'n-a';
  if (data.verdict) return normalizeVerdict(data.verdict);
  switch (archetype) {
    case 'findings': {
      const fs = data.findings || [];
      if (!fs.length) return 'allow';
      const crit = fs.some(function (f) { return /crit|kritisk/i.test(f.severity || ''); });
      return crit ? 'block' : 'warning';
    }
    case 'findings-grade': {
      const g = String(data.grade || '').toUpperCase();
      if (g === 'A' || g === 'B') return 'allow';
      if (g === 'C' || g === 'D') return 'warning';
      if (g === 'F') return 'block';
      return 'n-a';
    }
    case 'posture-cards': {
      const g = String(data.grade || '').toUpperCase();
      if (g === 'A' || g === 'B') return 'allow';
      if (g === 'C' || g === 'D') return 'warning';
      if (g === 'F') return 'block';
      return 'n-a';
    }
    case 'risk-score-meter': {
      const score = Number(data.risk_score);
      if (isNaN(score)) return 'n-a';
      if (score >= 65) return 'block';
      if (score >= 15) return 'warning';
      return 'allow';
    }
    case 'dashboard-fleet': {
      const g = String(data.machine_grade || '').toUpperCase();
      if (g === 'A' || g === 'B') return 'allow';
      if (g === 'C' || g === 'D') return 'warning';
      if (g === 'F') return 'block';
      return 'n-a';
    }
    case 'red-team-results': {
      const fail = Number(data.fail_count) || 0;
      if (fail > 5) return 'block';
      if (fail > 0) return 'warning';
      return 'allow';
    }
    case 'diff-report': {
      const newCount = (data['new'] || []).length;
      if (newCount > 0) return 'warning';
      return 'allow';
    }
    case 'kanban-buckets': {
      const remove = (data.remove || []).length;
      if (remove > 0) return 'warning';
      return 'allow';
    }
    case 'matrix-risk': {
      const threats = data.threats || data.findings || [];
      const hasCritical = threats.some(function (t) { return /crit|kritisk/i.test(t.severity || ''); });
      if (hasCritical) return 'block';
      if (threats.length) return 'warning';
      return 'n-a';
    }
    default:
      return 'n-a';
  }
}

const KEY_STATS_CONFIG = {
  'findings': function (d) {
    const fs = d.findings || [];
    const crit = fs.filter(function (f) { return /crit|kritisk/i.test(f.severity || ''); }).length;
    const high = fs.filter(function (f) { return /^high|^høy/i.test(f.severity || ''); }).length;
    return [
      { label: 'TOTAL', value: fs.length },
      { label: 'CRITICAL', value: crit, modifier: crit > 0 ? 'critical' : null },
      { label: 'HIGH', value: high, modifier: high > 0 ? 'high' : null }
    ];
  },
  'findings-grade': function (d) {
    const out = [];
    if (d.grade) out.push({ label: 'GRADE', value: String(d.grade).toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') });
    if (d.score != null) out.push({ label: 'SCORE', value: d.score });
    if (d.findings) out.push({ label: 'FINDINGS', value: d.findings.length });
    return out;
  },
  'risk-score-meter': function (d) {
    const out = [];
    if (d.risk_score != null) {
      const mod = d.risk_score >= 65 ? 'critical' : (d.risk_score >= 15 ? 'medium' : 'low');
      out.push({ label: 'RISK SCORE', value: d.risk_score, modifier: mod });
    }
    if (d.riskBand) out.push({ label: 'BAND', value: d.riskBand });
    return out;
  },
  'red-team-results': function (d) {
    return [
      { label: 'TOTAL', value: d.total || 0 },
      { label: 'PASS', value: d.pass_count || 0, modifier: 'low' },
      { label: 'FAIL', value: d.fail_count || 0, modifier: (d.fail_count > 0 ? 'critical' : null) }
    ];
  },
  'dashboard-fleet': function (d) {
    return [
      { label: 'PROJECTS', value: (d.projects || []).length },
      { label: 'MACHINE GRADE', value: String(d.machine_grade || 'n/a').toUpperCase() },
      { label: 'WEAKEST', value: d.weakest_link || '–' }
    ];
  },
  'posture-cards': function (d) {
    const cats = d.categories || [];
    const pass = cats.filter(function (c) { return c.status === 'PASS'; }).length;
    const fail = cats.filter(function (c) { return c.status === 'FAIL'; }).length;
    return [
      { label: 'GRADE', value: String(d.grade || '?').toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'medium' : 'critical') },
      { label: 'PASS', value: pass, modifier: 'low' },
      { label: 'FAIL', value: fail, modifier: fail > 0 ? 'critical' : 'low' }
    ];
  },
  'diff-report': function (d) {
    const newCount = (d['new'] || []).length;
    const unchangedCount = (d.unchanged || []).length;
    return [
      { label: 'CURRENT GRADE', value: String(d.current_grade || '?').toUpperCase() },
      { label: 'ACTIONS', value: newCount, modifier: newCount > 0 ? 'medium' : 'low' },
      { label: 'SKIPPED', value: unchangedCount }
    ];
  },
  'kanban-buckets': function (d) {
    const auto = (d.buckets && d.buckets.auto) || d.auto || [];
    const semi = (d.buckets && (d.buckets['semi-auto'] || d.buckets.semi_auto)) || d['semi-auto'] || d.semi_auto || [];
    const manual = (d.buckets && d.buckets.manual) || d.manual || [];
    return [
      { label: 'AUTO', value: auto.length, modifier: 'low' },
      { label: 'SEMI-AUTO', value: semi.length, modifier: semi.length ? 'medium' : 'low' },
      { label: 'MANUAL', value: manual.length, modifier: manual.length ? 'high' : 'low' }
    ];
  },
  'matrix-risk': function (d) {
    const threats = d.threats || d.findings || [];
    const cells = d.matrix_cells || [];
    const maxScore = cells.length ? Math.max.apply(null, cells.map(function (c) { return Number(c.score) || 0; })) : 0;
    const sev = maxScore >= 16 ? 'critical' : maxScore >= 9 ? 'high' : maxScore >= 4 ? 'medium' : 'low';
    return [
      { label: 'TRUSLER', value: threats.length },
      { label: 'MAKS SCORE', value: maxScore || '–', modifier: sev },
      { label: 'CELLER', value: cells.length }
    ];
  }
};

function inferKeyStats(data, archetype) {
  if (!data) return [];
  if (Array.isArray(data.keyStats)) return data.keyStats;
  const fn = KEY_STATS_CONFIG[archetype];
  if (typeof fn !== 'function') return [];
  try {
    const out = fn(data);
    return Array.isArray(out) ? out : [];
  } catch (e) { return []; }
}

// ============================================================
// PARSER HELPERS (markdown → struktur)
// ============================================================
function parseTableRow(line) {
  const inner = line.replace(/^\|/, '').replace(/\|$/, '');
  return inner.split('|').map(function (c) { return c.trim(); });
}

function parseTable(md, anchorRegex) {
  if (typeof md !== 'string') return null;
  let body = md;
  if (anchorRegex) {
    const m = anchorRegex.exec(md);
    if (!m) return null;
    body = md.slice(m.index + m[0].length);
  }
  const lines = body.split(/\r?\n/);
  for (let i = 0; i < lines.length - 1; i++) {
    const line = lines[i].trim();
    const next = (lines[i + 1] || '').trim();
    if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
      const headers = parseTableRow(line);
      const rows = [];
      for (let j = i + 2; j < lines.length; j++) {
        const rowLine = lines[j].trim();
        if (rowLine.indexOf('|') !== 0) break;
        const cells = parseTableRow(rowLine);
        if (cells.length === 0) break;
        const row = {};
        for (let k = 0; k < headers.length; k++) {
          row[headers[k]] = (cells[k] || '').trim();
        }
        rows.push(row);
      }
      return { headers: headers, rows: rows };
    }
  }
  return null;
}

function parseAllTables(md, anchorRegex) {
  // Returnerer alle tabeller etter (valgfri) anchor til neste H2
  // Brukt av parsers som har flere severity-tabeller (### Critical, ### High osv).
  if (typeof md !== 'string') return [];
  let body = md;
  if (anchorRegex) {
    const m = anchorRegex.exec(md);
    if (!m) return [];
    body = md.slice(m.index + m[0].length);
  }
  const out = [];
  const lines = body.split(/\r?\n/);
  let i = 0;
  while (i < lines.length - 1) {
    const line = lines[i].trim();
    const next = (lines[i + 1] || '').trim();
    if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
      const headers = parseTableRow(line);
      const rows = [];
      let j = i + 2;
      for (; j < lines.length; j++) {
        const rowLine = lines[j].trim();
        if (rowLine.indexOf('|') !== 0) break;
        const cells = parseTableRow(rowLine);
        if (cells.length === 0) break;
        const row = {};
        for (let k = 0; k < headers.length; k++) {
          row[headers[k]] = (cells[k] || '').trim();
        }
        rows.push(row);
      }
      out.push({ headers: headers, rows: rows });
      i = j;
    } else {
      i++;
    }
  }
  return out;
}

function parseSections(md) {
  if (typeof md !== 'string') return [];
  const sections = [];
  const lines = md.split(/\r?\n/);
  let current = null;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const m = /^##\s+(.+)$/.exec(line);
    if (m && line.charAt(2) === ' ') {
      if (current) sections.push(current);
      current = { heading: m[1].trim(), body: '' };
    } else if (current) {
      current.body += (current.body ? '\n' : '') + line;
    }
  }
  if (current) sections.push(current);
  return sections.map(function (s) {
    return { heading: s.heading, body: s.body.trim() };
  });
}

function extractField(md, label) {
  if (typeof md !== 'string') return null;
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  // Markdown-tabellrader: | **Label** | value | OR | Label | value |
  const tblRe = new RegExp('^\\s*\\|\\s*\\**\\s*' + escaped + '\\s*\\**\\s*\\|\\s*([^|]+?)\\s*\\|', 'mi');
  const tbl = tblRe.exec(md);
  if (tbl) return tbl[1].trim();
  // **Label:** value  OR  Label: value
  const re = new RegExp('^\\s*\\**\\s*' + escaped + '\\**\\s*:\\s*(.+)$', 'mi');
  const m = re.exec(md);
  return m ? m[1].trim() : null;
}

function intOrZero(s) {
  if (s == null) return 0;
  if (typeof s !== 'string') s = String(s);
  const v = parseInt(s.replace(/[^\d-]/g, ''), 10);
  return isNaN(v) ? 0 : v;
}

function emptyInput(md) {
  return !md || typeof md !== 'string' || !md.trim();
}

function normalizeSeverity(s) {
  const v = String(s || '').toLowerCase().trim();
  if (/crit|kritisk/.test(v)) return 'critical';
  if (/^high|^høy/.test(v)) return 'high';
  if (/medium|moderat/.test(v)) return 'medium';
  if (/^low|^lav/.test(v)) return 'low';
  if (/^info|^observ/.test(v)) return 'info';
  return v || 'info';
}

function normalizeVerdictText(s) {
  const v = String(s || '').toUpperCase().trim();
  if (/BLOCK|BLOKK|UNDERKJENT|FAIL/.test(v)) return 'block';
  if (/GO[-\s]WITH[-\s]CONDITIONS|CONDITIONAL|BETINGET/.test(v)) return 'go-with-conditions';
  if (/WARNING|ADVARSEL/.test(v)) return 'warning';
  if (/ALLOW|TILLATT|GO|PASS|GODKJENT/.test(v)) return 'allow';
  if (/N\/?A|IKKE/.test(v)) return 'n-a';
  return '';
}

function gradeFromText(s) {
  const m = /\b([A-F])\b/.exec(String(s || '').toUpperCase());
  return m ? m[1] : null;
}

// Helper: parse the Risk Dashboard table (shared pattern)
function parseRiskDashboard(md) {
  const out = {};
  const score = extractField(md, 'Risk Score');
  if (score) {
    const m = /(\d+)\s*\/\s*100/.exec(score);
    if (m) out.risk_score = parseInt(m[1], 10);
    else out.risk_score = intOrZero(score);
  }
  const band = extractField(md, 'Risk Band');
  if (band) out.riskBand = band;
  const grade = extractField(md, 'Grade');
  if (grade) out.grade = gradeFromText(grade);
  const verdict = extractField(md, 'Verdict');
  if (verdict) {
    const norm = normalizeVerdictText(verdict);
    if (norm) out.verdict = norm;
  }
  const rationale = extractField(md, 'Verdict rationale');
  if (rationale) out.verdict_rationale = rationale;
  // Severity counts-tabell (Severity | Count) — etter Risk Dashboard-headeren
  const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*Count/i);
  if (sevTbl && sevTbl.rows.length) {
    const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0, total: 0 };
    sevTbl.rows.forEach(function (row) {
      const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, '');
      const n = intOrZero(row[sevTbl.headers[1]] || '0');
      if (/^critical|^kritisk/.test(label)) counts.critical = n;
      else if (/^high|^høy/.test(label)) counts.high = n;
      else if (/^medium/.test(label)) counts.medium = n;
      else if (/^low|^lav/.test(label)) counts.low = n;
      else if (/^info/.test(label)) counts.info = n;
      else if (/^total/.test(label)) counts.total = n;
    });
    if (!counts.total) {
      counts.total = counts.critical + counts.high + counts.medium + counts.low + counts.info;
    }
    out.severity_counts = counts;
  }
  return out;
}

// Hjelper: parse alle findings-tabeller (### Critical / High / Medium / Low / Info)
function parseFindingsTables(md) {
  const findings = [];
  // Match alle ### <Severity>-headere innenfor ## Findings
  const findingsSection = parseSections(md).find(function (s) {
    return /^findings$/i.test(s.heading) || /^funn$/i.test(s.heading);
  });
  if (!findingsSection) return findings;
  const body = findingsSection.body;
  // Split on ### headers
  const subRe = /^###\s+(.+)$/gm;
  const matches = [];
  let m;
  while ((m = subRe.exec(body)) !== null) {
    matches.push({ severity: m[1].trim(), index: m.index });
  }
  for (let i = 0; i < matches.length; i++) {
    const start = matches[i].index;
    const end = i + 1 < matches.length ? matches[i + 1].index : body.length;
    const chunk = body.slice(start, end);
    const tbl = parseTable(chunk);
    if (!tbl || !tbl.rows.length) continue;
    const sev = matches[i].severity.split(/[\s/,]/)[0]; // "Low / Info" → "Low"
    tbl.rows.forEach(function (row) {
      const idKey = tbl.headers[0];
      const catKey = tbl.headers.find(function (h) { return /category|kategori/i.test(h); });
      const fileKey = tbl.headers.find(function (h) { return /file|fil/i.test(h); });
      const lineKey = tbl.headers.find(function (h) { return /^line$|linje/i.test(h); });
      const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); });
      const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); });
      findings.push({
        id: row[idKey] || '',
        severity: normalizeSeverity(sev),
        category: catKey ? row[catKey] : '',
        file: fileKey ? row[fileKey] : '',
        line: lineKey ? row[lineKey] : '',
        description: descKey ? row[descKey] : '',
        owasp: owaspKey ? row[owaspKey] : ''
      });
    });
  }
  return findings;
}

function parseRecommendations(md) {
  const sec = parseSections(md).find(function (s) { return /^recommendations$|^anbefalinger$/i.test(s.heading); });
  if (!sec) return [];
  const out = [];
  const lines = sec.body.split(/\r?\n/);
  lines.forEach(function (line) {
    const m = /^\s*(?:\d+\.|[-*])\s+(.+)$/.exec(line);
    if (m) out.push(m[1].replace(/^\*\*[^*]+\*\*[:]?\s*/, '').trim());
  });
  return out;
}

function safeOk(parser) {
  return function (md) {
    if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
    try { return parser(md); }
    catch (e) { return { ok: false, errors: [{ section: 'parser', reason: String(e && e.message || e) }] }; }
  };
}

// ============================================================
// parseNarrativeAudit — v7.1.1 Narrative Audit-blokk
// ============================================================
/**
 * Parse v7.1.1 Narrative Audit-blokk: "**Suppressed signals:** N (reason1: count examples, ...)"
 * Returnerer { count, by_category: {reason: count, ...}, examples: {reason: text, ...} } eller null.
 */
function parseNarrativeAudit(md) {
  const m = String(md || '').match(/Suppressed signals:\s*\*?\*?\s*(\d+)\s*(?:\(([^)]+)\))?/i);
  if (!m) return null;
  const count = Number(m[1]) || 0;
  const by_category = {};
  const examples = {};
  if (m[2]) {
    m[2].split(',').forEach(function (part) {
      const seg = part.trim();
      const colonIdx = seg.indexOf(':');
      if (colonIdx < 0) {
        by_category[seg] = (by_category[seg] || 0) + 1;
        return;
      }
      const reason = seg.slice(0, colonIdx).trim();
      const rest = seg.slice(colonIdx + 1).trim();
      const cm = rest.match(/^(\d+)\s+(.*)$/);
      if (cm) {
        by_category[reason] = (by_category[reason] || 0) + (Number(cm[1]) || 1);
        examples[reason] = cm[2].trim();
      } else {
        by_category[reason] = (by_category[reason] || 0) + 1;
        examples[reason] = rest;
      }
    });
  }
  return { count: count, by_category: by_category, examples: examples };
}

// ============================================================
// 10 PARSERS — one per high-priority command.
// Returner { ok: true, data: { ...domain-specific } } eller
//          { ok: false, errors: [{ section, reason }] }
// ============================================================
const parseScan = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const findings = parseFindingsTables(md);
  const owaspTbl = parseTable(md, /##\s+OWASP\s+Categorization/i);
  const owasp = owaspTbl ? owaspTbl.rows.map(function (row) {
    return {
      category: row[owaspTbl.headers[0]] || '',
      findings: intOrZero(row[owaspTbl.headers[1]] || '0'),
      max_severity: normalizeSeverity(row[owaspTbl.headers[2]] || ''),
      scanners: row[owaspTbl.headers[3]] || ''
    };
  }) : [];
  const supplyTbl = parseTable(md, /##\s+Supply\s+Chain\s+Assessment/i);
  const supply_chain = supplyTbl ? supplyTbl.rows.map(function (row) {
    return {
      component: row[supplyTbl.headers[0]] || '',
      type: row[supplyTbl.headers[1]] || '',
      source: row[supplyTbl.headers[2]] || '',
      trust: row[supplyTbl.headers[3]] || '',
      notes: row[supplyTbl.headers[4]] || ''
    };
  }) : [];
  const exec = parseSections(md).find(function (s) { return /^executive\s+summary/i.test(s.heading); });
  const suppressed = parseNarrativeAudit(md);
  return { ok: true, data: Object.assign({}, dash, {
    findings: findings,
    owasp: owasp,
    supply_chain: supply_chain,
    executive_summary: exec ? exec.body.split(/\n##/)[0].trim() : '',
    narrative_audit: suppressed ? { suppressed_findings: suppressed } : undefined,
    recommendations: parseRecommendations(md)
  }) };
});

const parseDeepScan = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Per-scanner-blokker: ### N. Name (TAG) — Status / Files / Findings / Time
  const scannerBlocks = [];
  const scannerRe = /^###\s+\d+\.\s+(.+?)\s+\(([A-Z]{2,4})\)\s*$([\s\S]*?)(?=^###\s+\d+\.|^##\s+|\Z)/gm;
  let m;
  while ((m = scannerRe.exec(md)) !== null) {
    const name = m[1].trim();
    const tag = m[2].trim();
    const body = m[3] || '';
    const statusMatch = /\*\*Status:\*\*\s*([^|]+?)\s*\|/i.exec(body);
    const filesMatch = /\*\*Files:\*\*\s*([^|]+?)\s*\|/i.exec(body);
    const findingsMatch = /\*\*Findings:\*\*\s*(\d+)/i.exec(body);
    const timeMatch = /\*\*Time:\*\*\s*(\d+)/i.exec(body);
    const detailLines = body.split(/\r?\n/).filter(function (l) {
      return l.trim() && !/^\*\*Status:\*\*/i.test(l.trim());
    });
    scannerBlocks.push({
      tag: tag,
      name: name,
      status: statusMatch ? statusMatch[1].trim() : '',
      files: filesMatch ? filesMatch[1].trim() : '',
      findings: findingsMatch ? parseInt(findingsMatch[1], 10) : 0,
      duration_ms: timeMatch ? parseInt(timeMatch[1], 10) : 0,
      details: detailLines.join(' ').trim()
    });
  }
  // Scanner Risk Matrix
  const matrixTbl = parseTable(md, /##\s+Scanner\s+Risk\s+Matrix/i);
  const scanner_matrix = matrixTbl ? matrixTbl.rows
    .filter(function (row) { return !/^\s*\*\*total/i.test(row[matrixTbl.headers[0]] || ''); })
    .map(function (row) {
      return {
        scanner: row[matrixTbl.headers[0]] || '',
        critical: intOrZero(row[matrixTbl.headers[1]] || '0'),
        high: intOrZero(row[matrixTbl.headers[2]] || '0'),
        medium: intOrZero(row[matrixTbl.headers[3]] || '0'),
        low: intOrZero(row[matrixTbl.headers[4]] || '0'),
        info: intOrZero(row[matrixTbl.headers[5]] || '0')
      };
    }) : [];
  const exec = parseSections(md).find(function (s) { return /^executive\s+summary/i.test(s.heading); });
  const suppressed = parseNarrativeAudit(md);
  return { ok: true, data: Object.assign({}, dash, {
    scanners: scannerBlocks,
    scanner_matrix: scanner_matrix,
    score: dash.risk_score,
    findings: parseFindingsTables(md),
    executive_summary: exec ? exec.body.split(/\n##/)[0].trim() : '',
    narrative_audit: suppressed ? { suppressed_findings: suppressed } : undefined,
    recommendations: parseRecommendations(md)
  }) };
});

const parsePluginAudit = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Plugin Metadata-tabell
  const metaTbl = parseTable(md, /##\s+Plugin\s+Metadata/i);
  const plugin_metadata = {};
  if (metaTbl) {
    metaTbl.rows.forEach(function (row) {
      const k = String(row[metaTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_');
      plugin_metadata[k] = row[metaTbl.headers[1]] || '';
    });
  }
  // Component Inventory
  const compTbl = parseTable(md, /##\s+Component\s+Inventory/i);
  const components = compTbl ? compTbl.rows.map(function (row) {
    return {
      component: row[compTbl.headers[0]] || '',
      count: intOrZero(row[compTbl.headers[1]] || '0'),
      notes: row[compTbl.headers[2]] || ''
    };
  }) : [];
  // Permission Matrix
  const permTbl = parseTable(md, /##\s+Permission\s+Matrix/i);
  const permissions = permTbl ? permTbl.rows.map(function (row) {
    return {
      tool: row[permTbl.headers[0]] || '',
      required_by: row[permTbl.headers[1]] || '',
      justified: row[permTbl.headers[2]] || ''
    };
  }) : [];
  // Trust Verdict-seksjon
  const sections = parseSections(md);
  const trustSec = sections.find(function (s) { return /trust\s+verdict/i.test(s.heading); });
  let trust_verdict_text = '';
  let trust_verdict_value = '';
  if (trustSec) {
    trust_verdict_text = trustSec.body;
    const vmatch = /\*\*Verdict:\*\*\s*([A-Z\-]+)/i.exec(trustSec.body);
    if (vmatch) trust_verdict_value = normalizeVerdictText(vmatch[1]);
  }
  return { ok: true, data: Object.assign({}, dash, {
    plugin_metadata: plugin_metadata,
    components: components,
    permissions: permissions,
    trust_verdict_text: trust_verdict_text,
    trust_verdict: trust_verdict_value || dash.verdict || '',
    findings: parseFindingsTables(md),
    recommendations: parseRecommendations(md)
  }) };
});

const parseMcpAudit = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // MCP Landscape-tabell
  const landTbl = parseTable(md, /##\s+MCP\s+Landscape/i);
  const mcp_servers = landTbl ? landTbl.rows.map(function (row) {
    return {
      server: row[landTbl.headers[0]] || '',
      type: row[landTbl.headers[1]] || '',
      trust: row[landTbl.headers[2]] || '',
      tools: intOrZero(row[landTbl.headers[3]] || '0'),
      active: /^yes|^aktiv|^ja/i.test(String(row[landTbl.headers[4]] || ''))
    };
  }) : [];
  // Per-Server-Analysis er fritekst-seksjoner med ### server-name
  const sections = parseSections(md);
  const perServerSec = sections.find(function (s) { return /per-server\s+analysis/i.test(s.heading); });
  const per_server = [];
  if (perServerSec) {
    const subRe = /^###\s+(.+)$/gm;
    const body = perServerSec.body;
    const heads = [];
    let m2;
    while ((m2 = subRe.exec(body)) !== null) heads.push({ name: m2[1].trim(), index: m2.index });
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      per_server.push({
        name: heads[i].name.replace(/\s*\([^)]+\)\s*$/, ''),
        note: heads[i].name.match(/\(([^)]+)\)/) ? heads[i].name.match(/\(([^)]+)\)/)[1] : '',
        body: body.slice(start, end).replace(/^###[^\n]+\n+/, '').trim()
      });
    }
  }
  // Keep / Review / Remove buckets
  const krrTbl = parseTable(md, /##\s+Keep\s*\/\s*Review\s*\/\s*Remove/i);
  const buckets = { keep: [], review: [], remove: [] };
  if (krrTbl) {
    krrTbl.rows.forEach(function (row) {
      const decision = String(row[krrTbl.headers[0]] || '').toLowerCase().trim();
      const item = {
        server: row[krrTbl.headers[1]] || '',
        reason: row[krrTbl.headers[2]] || ''
      };
      if (/^keep/.test(decision)) buckets.keep.push(item);
      else if (/^review/.test(decision)) buckets.review.push(item);
      else if (/^remove/.test(decision)) buckets.remove.push(item);
    });
  }
  // Findings: tabeller under ## Findings
  const findings = [];
  const findingsSec = sections.find(function (s) { return /^findings$/i.test(s.heading); });
  if (findingsSec) {
    const subRe = /^###\s+(.+)$/gm;
    const body = findingsSec.body;
    const heads = [];
    let m3;
    while ((m3 = subRe.exec(body)) !== null) heads.push({ severity: m3[1].trim(), index: m3.index });
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      const tbl = parseTable(chunk);
      if (!tbl || !tbl.rows.length) continue;
      const sev = heads[i].severity.split(/[\s/,]/)[0];
      tbl.rows.forEach(function (row) {
        const idKey = tbl.headers[0];
        const serverKey = tbl.headers.find(function (h) { return /server/i.test(h); });
        const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); });
        const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); });
        findings.push({
          id: row[idKey] || '',
          severity: normalizeSeverity(sev),
          server: serverKey ? row[serverKey] : '',
          description: descKey ? row[descKey] : '',
          owasp: owaspKey ? row[owaspKey] : ''
        });
      });
    }
  }
  return { ok: true, data: Object.assign({}, dash, {
    mcp_servers: mcp_servers,
    per_server: per_server,
    buckets: buckets,
    findings: findings,
    recommendations: parseRecommendations(md)
  }) };
});

const parseIdeScan = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Scan Coverage-tabell
  const covTbl = parseTable(md, /##\s+Scan\s+Coverage/i);
  const coverage = covTbl ? covTbl.rows
    .filter(function (row) { return !/^\s*\*\*total/i.test(row[covTbl.headers[0]] || ''); })
    .map(function (row) {
      return {
        ide: row[covTbl.headers[0]] || '',
        extensions: intOrZero(row[covTbl.headers[1]] || '0'),
        findings: intOrZero(row[covTbl.headers[2]] || '0')
      };
    }) : [];
  // Findings: under ### Critical/High/Medium/Low/Info — extension+IDE-spesifikk
  const findings = [];
  const sections = parseSections(md);
  const findingsSec = sections.find(function (s) { return /^findings$/i.test(s.heading); });
  if (findingsSec) {
    const body = findingsSec.body;
    const subRe = /^###\s+(.+)$/gm;
    const heads = [];
    let m;
    while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index });
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      const tbl = parseTable(chunk);
      if (!tbl || !tbl.rows.length) continue;
      const sev = heads[i].severity.split(/[\s/,]/)[0];
      tbl.rows.forEach(function (row) {
        const idKey = tbl.headers[0];
        const extKey = tbl.headers.find(function (h) { return /extension/i.test(h); });
        const ideKey = tbl.headers.find(function (h) { return /^ide$/i.test(h); });
        const descKey = tbl.headers.find(function (h) { return /description|beskriv/i.test(h); });
        const owaspKey = tbl.headers.find(function (h) { return /owasp/i.test(h); });
        findings.push({
          id: row[idKey] || '',
          severity: normalizeSeverity(sev),
          extension: extKey ? row[extKey] : '',
          ide: ideKey ? row[ideKey] : '',
          description: descKey ? row[descKey] : '',
          owasp: owaspKey ? row[owaspKey] : ''
        });
      });
    }
  }
  return { ok: true, data: Object.assign({}, dash, {
    coverage: coverage,
    findings: findings,
    recommendations: parseRecommendations(md)
  }) };
});

const parsePosture = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Overall Score-seksjon: "**N / M categories covered (Grade X)**"
  const overallSec = parseSections(md).find(function (s) { return /^overall\s+score/i.test(s.heading); });
  let posture_score = null;
  let posture_applicable = null;
  if (overallSec) {
    const m = /\*\*\s*(\d+)\s*\/\s*(\d+)\s+categories/i.exec(overallSec.body);
    if (m) {
      posture_score = parseInt(m[1], 10);
      posture_applicable = parseInt(m[2], 10);
    }
  }
  // Category Scorecard-tabell
  const catTbl = parseTable(md, /##\s+Category\s+Scorecard/i);
  const categories = catTbl ? catTbl.rows.map(function (row) {
    const status = String(row[catTbl.headers.find(function (h) { return /status/i.test(h); }) || catTbl.headers[2]] || '').toUpperCase().trim();
    return {
      num: intOrZero(row[catTbl.headers[0]] || '0'),
      name: row[catTbl.headers[1]] || '',
      status: status,
      findings: intOrZero(row[catTbl.headers[3]] || '0')
    };
  }) : [];
  // Top findings under ## Top Findings (med ### severity-grupper)
  const findings = [];
  const sections = parseSections(md);
  const topSec = sections.find(function (s) { return /^top\s+findings/i.test(s.heading); });
  if (topSec) {
    const body = topSec.body;
    const subRe = /^###\s+(.+)$/gm;
    const heads = [];
    let m;
    while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index });
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      const tbl = parseTable(chunk);
      if (!tbl || !tbl.rows.length) continue;
      tbl.rows.forEach(function (row) {
        findings.push({
          id: row[tbl.headers[0]] || '',
          severity: normalizeSeverity(heads[i].severity),
          category: row[tbl.headers[1]] || '',
          file: row[tbl.headers[2]] || '',
          description: row[tbl.headers[3]] || ''
        });
      });
    }
  }
  // Quick Wins
  const quickSec = sections.find(function (s) { return /^quick\s+wins/i.test(s.heading); });
  const quick_wins = quickSec ? quickSec.body.split(/\r?\n/).map(function (l) {
    const m = /^\s*\d+\.\s+(.+)$/.exec(l);
    return m ? m[1].replace(/^\*\*[^*]+\*\*\s*[—-]?\s*/, '').trim() : null;
  }).filter(Boolean) : [];
  return { ok: true, data: Object.assign({}, dash, {
    score: posture_score != null ? posture_score : dash.risk_score,
    posture_score: posture_score,
    posture_applicable: posture_applicable,
    categories: categories,
    findings: findings,
    quick_wins: quick_wins,
    recommendations: parseRecommendations(md)
  }) };
});

const parseAudit = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Radar Axes-tabell
  const radarTbl = parseTable(md, /##\s+Radar\s+Axes/i);
  const radar_axes = radarTbl ? radarTbl.rows.map(function (row) {
    return {
      name: row[radarTbl.headers[0]] || '',
      score: intOrZero(row[radarTbl.headers[1]] || '0')
    };
  }) : [];
  // Category Assessment: ### Category N — Name + status-tabell
  const sections = parseSections(md);
  const catAssessSec = sections.find(function (s) { return /^category\s+assessment/i.test(s.heading); });
  const categories = [];
  if (catAssessSec) {
    const body = catAssessSec.body;
    const subRe = /^###\s+Category\s+(\d+)\s+[—-]\s+(.+)$/gm;
    const heads = [];
    let m;
    while ((m = subRe.exec(body)) !== null) {
      heads.push({ num: parseInt(m[1], 10), name: m[2].trim(), index: m.index });
    }
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      const statusMatch = /\|\s*Status\s*\|\s*([A-Z\-]+)\s*\|/i.exec(chunk);
      categories.push({
        num: heads[i].num,
        name: heads[i].name,
        status: statusMatch ? statusMatch[1].trim().toUpperCase() : ''
      });
    }
  }
  // Risk Matrix (L×I)
  const riskTbl = parseTable(md, /##\s+Risk\s+Matrix/i);
  const risk_matrix = riskTbl ? riskTbl.rows.map(function (row) {
    return {
      category: row[riskTbl.headers[0]] || '',
      likelihood: intOrZero(row[riskTbl.headers[1]] || '0'),
      impact: intOrZero(row[riskTbl.headers[2]] || '0'),
      score: intOrZero(row[riskTbl.headers[3]] || '0')
    };
  }) : [];
  // Action Plan: ### IMMEDIATE / HIGH / MEDIUM
  const actionSec = sections.find(function (s) { return /^action\s+plan/i.test(s.heading); });
  const action_plan = { immediate: [], high: [], medium: [] };
  if (actionSec) {
    const body = actionSec.body;
    const subRe = /^###\s+(IMMEDIATE|HIGH|MEDIUM)/gmi;
    const heads = [];
    let m;
    while ((m = subRe.exec(body)) !== null) heads.push({ tier: m[1].toLowerCase(), index: m.index });
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      chunk.split(/\r?\n/).forEach(function (line) {
        const mm = /^\s*\d+\.\s+(.+)$/.exec(line);
        if (mm) action_plan[heads[i].tier].push(mm[1].trim());
      });
    }
  }
  const exec = sections.find(function (s) { return /^executive\s+summary/i.test(s.heading); });
  return { ok: true, data: Object.assign({}, dash, {
    score: dash.risk_score,
    radar_axes: radar_axes,
    categories: categories,
    risk_matrix: risk_matrix,
    action_plan: action_plan,
    findings: parseFindingsTables(md),
    executive_summary: exec ? exec.body.trim() : ''
  }) };
});

const parseDashboard = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Header-Risk Dashboard-tabell har egne felter
  const machine_grade = gradeFromText(extractField(md, 'Machine Grade') || '');
  const projects_scanned = intOrZero(extractField(md, 'Projects Scanned') || '0');
  const total_findings = intOrZero(extractField(md, 'Total Findings') || '0');
  const cache = extractField(md, 'Cache') || '';
  // Project Overview-tabell
  const projTbl = parseTable(md, /##\s+Project\s+Overview/i);
  const projects = projTbl ? projTbl.rows.map(function (row) {
    return {
      name: row[projTbl.headers[0]] || '',
      grade: gradeFromText(row[projTbl.headers[1]] || ''),
      risk: intOrZero(row[projTbl.headers[2]] || '0'),
      worst_category: row[projTbl.headers[3]] || '',
      findings: intOrZero(row[projTbl.headers[4]] || '0')
    };
  }) : [];
  // Trend-tabell
  const trendTbl = parseTable(md, /##\s+Trend/i);
  const trends = trendTbl ? trendTbl.rows.map(function (row) {
    return {
      name: row[trendTbl.headers[0]] || '',
      trend: String(row[trendTbl.headers[1]] || '').toLowerCase().trim(),
      d_risk: row[trendTbl.headers[2]] || '',
      d_findings: row[trendTbl.headers[3]] || ''
    };
  }) : [];
  // Errors-seksjon
  const errSec = parseSections(md).find(function (s) { return /^errors/i.test(s.heading); });
  let errors = [];
  if (errSec) {
    const errTbl = parseTable(errSec.body);
    if (errTbl) {
      errors = errTbl.rows.map(function (row) {
        return {
          project: row[errTbl.headers[0]] || '',
          error: row[errTbl.headers[errTbl.headers.length - 1]] || ''
        };
      });
    }
  }
  // Weakest link = first project, sorted worst-first (already sorted in the fixture)
  const weakest = projects.length ? projects[0].name : '';
  return { ok: true, data: Object.assign({}, dash, {
    machine_grade: machine_grade,
    projects_scanned: projects_scanned,
    total_findings: total_findings,
    cache: cache,
    projects: projects,
    trends: trends,
    errors: errors,
    weakest_link: weakest,
    recommendations: parseRecommendations(md)
  }) };
});

const parseHarden = safeOk(function (md) {
  const current_grade = gradeFromText(extractField(md, 'Current Grade') || '');
  const project_type = extractField(md, 'Project Type') || '';
  const recRaw = extractField(md, 'Recommendations') || '';
  let actionable = 0, total = 0;
  const recMatch = /(\d+)\s*\/\s*(\d+)/.exec(recRaw);
  if (recMatch) { actionable = parseInt(recMatch[1], 10); total = parseInt(recMatch[2], 10); }
  const mode = extractField(md, 'Mode') || 'dry-run';
  // Recommendations: ### N. Category — File med Action / Content preview
  const sections = parseSections(md);
  const recSec = sections.find(function (s) { return /^recommendations$/i.test(s.heading); });
  const recommendations = [];
  if (recSec) {
    const body = recSec.body;
    const subRe = /^###\s+(\d+)\.\s+(.+?)\s+[—-]\s+(.+)$/gm;
    const heads = [];
    let m;
    while ((m = subRe.exec(body)) !== null) {
      heads.push({ num: parseInt(m[1], 10), category: m[2].trim(), file: m[3].trim(), index: m.index });
    }
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      const actionMatch = /-\s+\*\*Action:\*\*\s*(.+)$/im.exec(chunk);
      const contentMatch = /-\s+\*\*Content preview:\*\*\s*([\s\S]*?)(?=\n-\s+\*\*|\n###|\n##|$)/i.exec(chunk);
      recommendations.push({
        num: heads[i].num,
        category: heads[i].category,
        file: heads[i].file,
        action: actionMatch ? actionMatch[1].trim() : '',
        content_preview: contentMatch ? contentMatch[1].trim() : ''
      });
    }
  }
  // Diff Summary-tabell
  const diffTbl = parseTable(md, /##\s+Diff\s+Summary/i);
  const diff_summary = diffTbl ? diffTbl.rows
    .filter(function (row) { return !/^\s*\*\*total/i.test(row[diffTbl.headers[0]] || ''); })
    .map(function (row) {
      return {
        file: row[diffTbl.headers[0]] || '',
        action: row[diffTbl.headers[1]] || '',
        lines: row[diffTbl.headers[2]] || ''
      };
    }) : [];
  // Map til diff-archetype: new = create, resolved = (none), unchanged = skipped
  const newItems = recommendations.filter(function (r) { return /create|append|merge/i.test(r.action); });
  const skippedItems = recommendations.filter(function (r) { return /none|skip/i.test(r.action); });
  return { ok: true, data: {
    current_grade: current_grade,
    project_type: project_type,
    actionable: actionable,
    total: total,
    mode: mode,
    recommendations: recommendations,
    diff_summary: diff_summary,
    'new': newItems,
    unchanged: skippedItems,
    resolved: [],
    moved: []
  } };
});

const parseRedTeam = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const defenseRaw = extractField(md, 'Defense Score') || '';
  const defense_score = intOrZero(defenseRaw);
  const total = intOrZero(extractField(md, 'Total Scenarios') || '0');
  const pass_count = intOrZero(extractField(md, 'Pass') || '0');
  const fail_count = intOrZero(extractField(md, 'Fail') || '0');
  const adaptive = /^on/i.test(String(extractField(md, 'Adaptive Mode') || ''));
  // Per-Category Breakdown-tabell
  const catTbl = parseTable(md, /##\s+Per-Category\s+Breakdown/i);
  const categories = catTbl ? catTbl.rows.map(function (row) {
    return {
      category: row[catTbl.headers[0]] || '',
      pass: intOrZero(row[catTbl.headers[1]] || '0'),
      fail: intOrZero(row[catTbl.headers[2]] || '0'),
      coverage: row[catTbl.headers[3]] || ''
    };
  }) : [];
  // Failed Scenarios med severity-grupper
  const sections = parseSections(md);
  const failSec = sections.find(function (s) { return /failed\s+scenarios/i.test(s.heading); });
  const scenarios = [];
  if (failSec) {
    const body = failSec.body;
    const subRe = /^###\s+(.+)$/gm;
    const heads = [];
    let m;
    while ((m = subRe.exec(body)) !== null) heads.push({ severity: m[1].trim(), index: m.index });
    for (let i = 0; i < heads.length; i++) {
      const start = heads[i].index;
      const end = i + 1 < heads.length ? heads[i + 1].index : body.length;
      const chunk = body.slice(start, end);
      const tbl = parseTable(chunk);
      if (!tbl || !tbl.rows.length) continue;
      tbl.rows.forEach(function (row) {
        scenarios.push({
          id: row[tbl.headers[0]] || '',
          severity: normalizeSeverity(heads[i].severity),
          category: row[tbl.headers[1]] || '',
          payload_class: row[tbl.headers[2]] || '',
          reason: row[tbl.headers[3]] || ''
        });
      });
    }
  }
  // Test History
  const histTbl = parseTable(md, /##\s+Test\s+History/i);
  const history = histTbl ? histTbl.rows.map(function (row) {
    return {
      run: row[histTbl.headers[0]] || '',
      date: row[histTbl.headers[1]] || '',
      defense_score: intOrZero(row[histTbl.headers[2]] || '0'),
      delta: row[histTbl.headers[3]] || ''
    };
  }) : [];
  return { ok: true, data: Object.assign({}, dash, {
    defense_score: defense_score,
    total: total,
    pass_count: pass_count,
    fail_count: fail_count,
    adaptive: adaptive,
    categories: categories,
    scenarios: scenarios,
    history: history,
    recommendations: parseRecommendations(md)
  }) };
});

// ============================================================
// PHASE 3: 8 PARSERS — one per remaining produces_report command.
// Patterns are reused from Phase 2 (parseRiskDashboard + parseFindingsTables
// + safeOk). Matrix-risk-parsing er kopiert fra ms-ai-architect.
// ============================================================
const parseMcpInspect = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const invTbl = parseTable(md, /##\s+Server\s+Inventory/i);
  const server_inventory = invTbl ? invTbl.rows.map(function (row) {
    return {
      server: row[invTbl.headers[0]] || '',
      transport: row[invTbl.headers[1]] || '',
      tools: intOrZero(row[invTbl.headers[2]] || '0'),
      status: row[invTbl.headers[3]] || '',
      connected: /^yes|^ja/i.test(String(row[invTbl.headers[4]] || ''))
    };
  }) : [];
  const cpTbl = parseTable(md, /##\s+Codepoint\s+Reveal/i);
  const codepoints = cpTbl ? cpTbl.rows.map(function (row) {
    return {
      server: row[cpTbl.headers[0]] || '',
      tool: row[cpTbl.headers[1]] || '',
      codepoints: row[cpTbl.headers[2]] || '',
      risk: row[cpTbl.headers[3]] || ''
    };
  }) : [];
  // Findings: merge default finding-shape med server-spesifikk meta
  const findingsRaw = parseFindingsTables(md);
  const findings = findingsRaw.map(function (f) {
    // Severity-tabellene bruker «Server» som kolonne → category=Server, file=tom
    return Object.assign({}, f, {
      server: f.category || f.file || '',
      file: f.file || ''
    });
  });
  return { ok: true, data: Object.assign({}, dash, {
    server_inventory: server_inventory,
    codepoints: codepoints,
    findings: findings,
    recommendations: parseRecommendations(md)
  }) };
});

const parseSupplyCheck = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const ecoTbl = parseTable(md, /##\s+Ecosystem\s+Coverage/i);
  const ecosystems = ecoTbl ? ecoTbl.rows
    .filter(function (row) { return !/^\s*\*\*total/i.test(row[ecoTbl.headers[0]] || ''); })
    .map(function (row) {
      return {
        ecosystem: row[ecoTbl.headers[0]] || '',
        lockfile: row[ecoTbl.headers[1]] || '',
        packages: intOrZero(row[ecoTbl.headers[2]] || '0'),
        osv_hits: intOrZero(row[ecoTbl.headers[3]] || '0'),
        typosquats: intOrZero(row[ecoTbl.headers[4]] || '0')
      };
    }) : [];
  return { ok: true, data: Object.assign({}, dash, {
    ecosystems: ecosystems,
    findings: parseFindingsTables(md),
    recommendations: parseRecommendations(md)
  }) };
});

const parsePreDeploy = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const lightTbl = parseTable(md, /##\s+Traffic\s+Light\s+Categories/i);
  const traffic_lights = lightTbl ? lightTbl.rows.map(function (row) {
    const status = String(row[lightTbl.headers[1]] || '').toUpperCase().trim();
    return {
      category: row[lightTbl.headers[0]] || '',
      status: status,
      notes: row[lightTbl.headers[2]] || ''
    };
  }) : [];
  const condSec = parseSections(md).find(function (s) { return /^conditions/i.test(s.heading); });
  const conditions = condSec ? condSec.body.split(/\r?\n/).map(function (l) {
    const m = /^\s*\d+\.\s+(.+)$/.exec(l);
    return m ? m[1].replace(/^\*\*[^*]+\*\*\s*[—:-]?\s*/, '').trim() : null;
  }).filter(Boolean) : [];
  const apprTbl = parseTable(md, /##\s+Approvals/i);
  const approvals = apprTbl ? apprTbl.rows.map(function (row) {
    return {
      role: row[apprTbl.headers[0]] || '',
      approver: row[apprTbl.headers[1]] || '',
      date: row[apprTbl.headers[2]] || '',
      notes: row[apprTbl.headers[3]] || ''
    };
  }) : [];
  return { ok: true, data: Object.assign({}, dash, {
    traffic_lights: traffic_lights,
    conditions: conditions,
    approvals: approvals,
    findings: parseFindingsTables(md),
    recommendations: parseRecommendations(md)
  }) };
});

const parseDiff = safeOk(function (md) {
  // NB: diff har egen severity-tabell (New/Resolved/Unchanged) — bruker
  // ikke parseRiskDashboard sin Count-kolonne.
  const dash = parseRiskDashboard(md);
  const current_grade = gradeFromText(extractField(md, 'Current Grade') || dash.grade || '');
  const baseline_grade = gradeFromText(extractField(md, 'Baseline Grade') || '');
  const baseline_date = extractField(md, 'Baseline') || '';
  // Per-severity matrix (Severity | New | Resolved | Unchanged)
  const sevTbl = parseTable(md, /\|\s*Severity\s*\|\s*New\s*\|\s*Resolved/i);
  const severity_matrix = { critical: {}, high: {}, medium: {}, low: {}, info: {} };
  if (sevTbl) {
    sevTbl.rows.forEach(function (row) {
      const label = String(row[sevTbl.headers[0]] || '').toLowerCase().replace(/[*\s]/g, '');
      const key = /^crit/.test(label) ? 'critical' :
                  /^high/.test(label) ? 'high' :
                  /^medium/.test(label) ? 'medium' :
                  /^low/.test(label) ? 'low' :
                  /^info/.test(label) ? 'info' : null;
      if (!key) return;
      severity_matrix[key] = {
        'new': intOrZero(row[sevTbl.headers[1]] || '0'),
        resolved: intOrZero(row[sevTbl.headers[2]] || '0'),
        unchanged: intOrZero(row[sevTbl.headers[3]] || '0')
      };
    });
  }
  // Per-bucket finding-tabeller
  const newTbl = parseTable(md, /##\s+New\s*\(?\d*\)?/i);
  const newItems = newTbl ? newTbl.rows.map(function (row) {
    const idKey = newTbl.headers[0];
    const sevKey = newTbl.headers.find(function (h) { return /severity/i.test(h); });
    const catKey = newTbl.headers.find(function (h) { return /category|kategori/i.test(h); });
    const fileKey = newTbl.headers.find(function (h) { return /file|fil/i.test(h); });
    const descKey = newTbl.headers.find(function (h) { return /description|beskriv/i.test(h); });
    const owaspKey = newTbl.headers.find(function (h) { return /owasp/i.test(h); });
    return {
      id: row[idKey] || '',
      severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
      category: catKey ? row[catKey] : '',
      file: fileKey ? row[fileKey] : '',
      description: descKey ? row[descKey] : '',
      owasp: owaspKey ? row[owaspKey] : ''
    };
  }) : [];
  const resolvedTbl = parseTable(md, /##\s+Resolved\s*\(?\d*\)?/i);
  const resolvedItems = resolvedTbl ? resolvedTbl.rows.map(function (row) {
    const idKey = resolvedTbl.headers[0];
    const sevKey = resolvedTbl.headers.find(function (h) { return /severity/i.test(h); });
    const catKey = resolvedTbl.headers.find(function (h) { return /category|kategori/i.test(h); });
    const fileKey = resolvedTbl.headers.find(function (h) { return /file|fil/i.test(h); });
    const resKey = resolvedTbl.headers.find(function (h) { return /resolution|løsning/i.test(h); });
    return {
      id: row[idKey] || '',
      severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
      category: catKey ? row[catKey] : '',
      file: fileKey ? row[fileKey] : '',
      resolution: resKey ? row[resKey] : ''
    };
  }) : [];
  const unchangedTbl = parseTable(md, /##\s+Unchanged\s*\(?\d*\)?/i);
  const unchangedItems = unchangedTbl ? unchangedTbl.rows.map(function (row) {
    const idKey = unchangedTbl.headers[0];
    const sevKey = unchangedTbl.headers.find(function (h) { return /severity/i.test(h); });
    const catKey = unchangedTbl.headers.find(function (h) { return /category|kategori/i.test(h); });
    const fileKey = unchangedTbl.headers.find(function (h) { return /file|fil/i.test(h); });
    const noteKey = unchangedTbl.headers.find(function (h) { return /notes|note|merknad/i.test(h); });
    return {
      id: row[idKey] || '',
      severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
      category: catKey ? row[catKey] : '',
      file: fileKey ? row[fileKey] : '',
      notes: noteKey ? row[noteKey] : ''
    };
  }) : [];
  const movedTbl = parseTable(md, /##\s+Moved\s*\(?\d*\)?/i);
  const movedItems = movedTbl ? movedTbl.rows.map(function (row) {
    return {
      id: row[movedTbl.headers[0]] || '',
      from: row[movedTbl.headers[1]] || '',
      to: row[movedTbl.headers[2]] || ''
    };
  }) : [];
  return { ok: true, data: Object.assign({}, dash, {
    current_grade: current_grade,
    baseline_grade: baseline_grade,
    baseline_date: baseline_date,
    severity_matrix: severity_matrix,
    'new': newItems,
    resolved: resolvedItems,
    unchanged: unchangedItems,
    moved: movedItems,
    recommendations: parseRecommendations(md)
  }) };
});

const parseWatch = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const meterTbl = parseTable(md, /##\s+Live\s+Meter/i);
  const live_meter = {};
  if (meterTbl) {
    meterTbl.rows.forEach(function (row) {
      const k = String(row[meterTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_');
      live_meter[k] = row[meterTbl.headers[1]] || '';
    });
  }
  const histTbl = parseTable(md, /##\s+Recent\s+History/i);
  const history = histTbl ? histTbl.rows.map(function (row) {
    return {
      run: row[histTbl.headers[0]] || '',
      time: row[histTbl.headers[1]] || '',
      grade: gradeFromText(row[histTbl.headers[2]] || ''),
      risk_score: intOrZero(row[histTbl.headers[3]] || '0'),
      delta: row[histTbl.headers[4]] || ''
    };
  }) : [];
  const notTbl = parseTable(md, /##\s+Notify\s+Events/i);
  const notify_events = notTbl ? notTbl.rows.map(function (row) {
    return {
      time: row[notTbl.headers[0]] || '',
      event: row[notTbl.headers[1]] || '',
      channel: row[notTbl.headers[2]] || '',
      status: row[notTbl.headers[3]] || ''
    };
  }) : [];
  return { ok: true, data: Object.assign({}, dash, {
    live_meter: live_meter,
    history: history,
    notify_events: notify_events,
    findings: parseFindingsTables(md),
    recommendations: parseRecommendations(md),
    interval: extractField(md, 'Interval') || '',
    last_run: extractField(md, 'Last Run') || ''
  }) };
});

const parseRegistry = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const statsTbl = parseTable(md, /##\s+Registry\s+Stats/i);
  const stats = {};
  if (statsTbl) {
    statsTbl.rows.forEach(function (row) {
      const k = String(row[statsTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/\s+/g, '_');
      stats[k] = row[statsTbl.headers[1]] || '';
    });
  }
  const sigTbl = parseTable(md, /##\s+Signature\s+Table/i);
  const signatures = sigTbl ? sigTbl.rows.map(function (row) {
    return {
      skill: row[sigTbl.headers[0]] || '',
      source: row[sigTbl.headers[1]] || '',
      fingerprint: row[sigTbl.headers[2]] || '',
      status: String(row[sigTbl.headers[3]] || '').toUpperCase().trim(),
      first_seen: row[sigTbl.headers[4]] || ''
    };
  }) : [];
  // Findings — bruk renderFindingsBlock men med skill+file som meta
  const findingsRaw = parseFindingsTables(md);
  const findings = findingsRaw.map(function (f) {
    // Tabell-header: «Skill» som 3. kolonne maps til category i parseFindingsTables
    return Object.assign({}, f, {
      skill: f.category || '',
      file: f.file || ''
    });
  });
  return { ok: true, data: Object.assign({}, dash, {
    stats: stats,
    signatures: signatures,
    findings: findings,
    recommendations: parseRecommendations(md)
  }) };
});

const parseClean = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  const sumTbl = parseTable(md, /##\s+Remediation\s+Summary/i);
  const summary = {};
  if (sumTbl) {
    sumTbl.rows
      .filter(function (row) { return !/^\s*\*\*total/i.test(row[sumTbl.headers[0]] || ''); })
      .forEach(function (row) {
        const k = String(row[sumTbl.headers[0]] || '').replace(/\*+/g, '').trim().toLowerCase().replace(/[\s-]/g, '_');
        summary[k] = {
          count: intOrZero(row[sumTbl.headers[1]] || '0'),
          action: row[sumTbl.headers[2]] || ''
        };
      });
  }
  // Per-bucket-tabeller (Auto / Semi-auto / Manual / Suppressed)
  const bucketParse = function (heading) {
    const tbl = parseTable(md, new RegExp('##\\s+' + heading + '\\s*$', 'mi'));
    if (!tbl || !tbl.rows.length) return [];
    return tbl.rows.map(function (row) {
      const idKey = tbl.headers[0];
      const actKey = tbl.headers[1];
      const descKey = tbl.headers[2];
      return {
        id: row[idKey] || '',
        action: row[actKey] || '',
        description: row[descKey] || ''
      };
    });
  };
  const buckets = {
    auto: bucketParse('Auto'),
    'semi-auto': bucketParse('Semi-auto'),
    manual: bucketParse('Manual'),
    suppressed: bucketParse('Suppressed')
  };
  return { ok: true, data: Object.assign({}, dash, {
    summary: summary,
    buckets: buckets,
    findings: parseFindingsTables(md),
    recommendations: parseRecommendations(md),
    mode: extractField(md, 'Mode') || ''
  }) };
});

const parseThreatModel = safeOk(function (md) {
  const dash = parseRiskDashboard(md);
  // Risikomatrise: Trussel | Sannsynlighet | Konsekvens | Score
  const matrixTbl = parseTable(md, /##\s+Risikomatrise/i);
  const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) {
    const labelKey = matrixTbl.headers[0];
    const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); }) || matrixTbl.headers[1];
    const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); }) || matrixTbl.headers[2];
    const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); }) || matrixTbl.headers[3];
    return {
      label: row[labelKey] || '',
      prob: intOrZero(row[sannKey] || '0'),
      cons: intOrZero(row[konsKey] || '0'),
      score: intOrZero(row[scoreKey] || '0')
    };
  }) : [];
  // Trusler: ID | Beskrivelse | Severity | Mitigation
  const threatsTbl = parseTable(md, /##\s+Trusler/i);
  const threats = threatsTbl ? threatsTbl.rows.map(function (row) {
    const idKey = threatsTbl.headers[0];
    const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1];
    const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
    const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
    return {
      id: row[idKey] || '',
      description: row[descKey] || '',
      severity: normalizeSeverity(sevKey ? row[sevKey] : ''),
      mitigation: mitKey ? row[mitKey] : ''
    };
  }) : [];
  // STRIDE / MAESTRO Coverage
  const strideTbl = parseTable(md, /##\s+STRIDE\s+Coverage/i);
  const stride = strideTbl ? strideTbl.rows.map(function (row) {
    return {
      category: row[strideTbl.headers[0]] || '',
      count: intOrZero(row[strideTbl.headers[1]] || '0'),
      notes: row[strideTbl.headers[2]] || ''
    };
  }) : [];
  const maestroTbl = parseTable(md, /##\s+MAESTRO\s+Coverage/i);
  const maestro = maestroTbl ? maestroTbl.rows.map(function (row) {
    return {
      layer: row[maestroTbl.headers[0]] || '',
      count: intOrZero(row[maestroTbl.headers[1]] || '0'),
      notes: row[maestroTbl.headers[2]] || ''
    };
  }) : [];
  // Mitigation Roadmap
  const roadTbl = parseTable(md, /##\s+Mitigation\s+Roadmap/i);
  const roadmap = roadTbl ? roadTbl.rows.map(function (row) {
    return {
      priority: row[roadTbl.headers[0]] || '',
      threat_id: row[roadTbl.headers[1]] || '',
      mitigation: row[roadTbl.headers[2]] || '',
      owner: row[roadTbl.headers[3]] || '',
      eta: row[roadTbl.headers[4]] || ''
    };
  }) : [];
  return { ok: true, data: Object.assign({}, dash, {
    matrix_cells: matrix_cells,
    threats: threats,
    stride: stride,
    maestro: maestro,
    roadmap: roadmap,
    recommendations: parseRecommendations(md),
    framework: extractField(md, 'Framework') || ''
  }) };
});

// ============================================================
// PARSERS routing-map (commandId → parser). 18 produces_report=true.
// ============================================================
const PARSERS = {
  'scan': parseScan,
  'deep-scan': parseDeepScan,
  'plugin-audit': parsePluginAudit,
  'mcp-audit': parseMcpAudit,
  'mcp-inspect': parseMcpInspect,
  'ide-scan': parseIdeScan,
  'supply-check': parseSupplyCheck,
  'posture': parsePosture,
  'audit': parseAudit,
  'dashboard': parseDashboard,
  'pre-deploy': parsePreDeploy,
  'diff': parseDiff,
  'watch': parseWatch,
  'registry': parseRegistry,
  'clean': parseClean,
  'harden': parseHarden,
  'threat-model': parseThreatModel,
  'red-team': parseRedTeam
};

// ============================================================
// RENDERERS routing-map — populated inline after each renderer-fn.
// ============================================================
const RENDERERS = {};

// ============================================================
// RENDERER HELPERS
// ============================================================
function renderEmptyState(message) {
  return '<div class="guide-panel guide-panel--info">' +
    '<div class="guide-panel__icon" aria-hidden="true">i</div>' +
    '<div class="guide-panel__body">' +
      '<p class="guide-panel__text">' + escapeHtml(message || 'No data to display.') + '</p>' +
    '</div>' +
  '</div>';
}

function renderFindingsBlock(findings, label) {
  if (!findings || !findings.length) return '';
  const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
  const sorted = findings.slice().sort(function (a, b) {
    return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9);
  });
  const items = sorted.map(function (f) {
    const sev = String(f.severity || 'info').toLowerCase();
    // DS Tier 3 (v7.6.0 phase 5h): card--severity-{level} modifier on the outer
    // .findings__item gir severity-tinted left-border. Beholdes ved siden av
    // den eksisterende .findings__item-severity-dot for ARIA + visuell
    // redundans (border-farge + dot-fyll signaliserer samme severity).
    const sevClass = 'card--severity-' + (sev === 'info' ? 'info' : sev);
    const meta = [
      f.file ? f.file + (f.line ? ':' + f.line : '') : '',
      f.category || '',
      f.owasp || ''
    ].filter(Boolean).join(' · ');
    return (
      '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
        '<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
        '<div>' +
          '<div class="findings__item-id">' + escapeHtml(f.id || '—') + '</div>' +
          '<div class="findings__item-title">' + escapeHtml(f.description || f.title || '') + '</div>' +
          (meta ? '<div class="findings__item-meta">' + escapeHtml(meta) + '</div>' : '') +
        '</div>' +
      '</div>'
    );
  }).join('');
  // DS .findings outer-class er et 2-kolonners grid (360px list + 1fr detail-panel) —
  // the playground uses only the list part, so we wrap in .findings__list (no outer
  // .findings) to avoid the header landing in the left 360px column. v7.6.1 fix.
  return (
    '<section class="report-meta">' +
      '<h4>' + escapeHtml(label || 'Funn') + '</h4>' +
      '<div class="findings__list" style="max-height: none;">' +
        '<div class="findings__group">' +
          '<div class="findings__group-header">' + escapeHtml(label || 'Funn') + ' (' + findings.length + ')</div>' +
          '<div class="findings__items">' + items + '</div>' +
        '</div>' +
      '</div>' +
    '</section>'
  );
}

/**
 * Render recommendation-card med ordnet liste av anbefalinger.
 * Tredje argument (severity) styrer DS-tier3 `data-severity`-attributtet:
 * 'critical' / 'high' / 'medium' / 'low' / 'positive'. Default 'low'
 * (info-tonet). Mapping: severity → border-left-farge + label-bakgrunn.
 */
function renderRecommendationsList(recs, label, severity) {
  if (!recs || !recs.length) return '';
  const sev = severity || 'low';
  const items = recs.map(function (r) { return '<li>' + escapeHtml(r) + '</li>'; }).join('');
  return (
    '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
      '<span class="recommendation-card__label">' + escapeHtml(label || 'Anbefalinger') + '</span>' +
      '<ol class="recommendation-card__body">' + items + '</ol>' +
    '</section>'
  );
}

/**
 * Map severity-string til DS-tier3 recommendation-card data-severity.
 * Accepts both severity conventions (critical/high/medium/low/info)
 * og action-types (CREATE/APPEND/MERGE/SKIP/NONE).
 */
function mapSeverityToCardLevel(input) {
  const s = String(input || '').toLowerCase().trim();
  if (!s) return 'low';
  if (s === 'critical' || s === 'crit') return 'critical';
  if (s === 'high') return 'high';
  if (s === 'medium' || s === 'med') return 'medium';
  if (s === 'low') return 'low';
  if (s === 'info') return 'low';
  if (s === 'positive' || s === 'success' || s === 'ok' || s === 'pass') return 'positive';
  // Action-types fra renderHarden
  if (s === 'create') return 'positive';
  if (s === 'append') return 'medium';
  if (s === 'merge') return 'low';
  if (s === 'skip' || s === 'none') return 'low';
  return 'low';
}

function renderRiskMeter(score, band) {
  const s = Math.max(0, Math.min(100, Number(score) || 0));
  const bands = [
    { label: 'Low', from: 0, to: 14 },
    { label: 'Medium', from: 15, to: 39 },
    { label: 'High', from: 40, to: 64 },
    { label: 'Critical', from: 65, to: 84 },
    { label: 'Extreme', from: 85, to: 100 }
  ];
  const labels = bands.map(function (b) {
    const w = (b.to - b.from + 1);
    return '<span class="risk-meter__band-label" data-band="' + escapeAttr(b.label.toLowerCase()) + '" style="flex: ' + w + '; text-align: center; min-width: 0;">' + escapeHtml(b.label) + '</span>';
  }).join('');
  return (
    '<div class="risk-meter">' +
      '<div class="risk-meter__readout"><span class="risk-meter__score">' + s + '</span><span> / 100 · ' + escapeHtml(band || '') + '</span></div>' +
      '<div class="risk-meter__track"><div class="risk-meter__pointer" style="left: ' + s + '%"></div></div>' +
      '<div class="risk-meter__bands">' + labels + '</div>' +
      '<div class="risk-meter__scale"><span>0</span><span>50</span><span>100</span></div>' +
    '</div>'
  );
}

function renderSmallMultiples(items) {
  // items: [{ name, score, max, grade?, status? }]
  if (!items || !items.length) return '';
  const cards = items.map(function (it) {
    const score = Number(it.score) || 0;
    const max = Number(it.max) || 5;
    const pct = Math.max(0, Math.min(100, (score / max) * 100));
    const grade = it.grade || '';
    const gradeAttr = grade ? ' data-grade="' + escapeAttr(grade) + '"' : '';
    return (
      '<div class="sm-card">' +
        '<div class="sm-card__header">' +
          '<span class="sm-card__name">' + escapeHtml(it.name || '') + '</span>' +
          (grade ? '<span class="sm-card__grade"' + gradeAttr + '>' + escapeHtml(grade) + '</span>' : '') +
        '</div>' +
        '<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + pct.toFixed(0) + '%"></div></div>' +
        '<span class="sm-card__status">' + escapeHtml(it.status || (score + ' / ' + max)) + '</span>' +
      '</div>'
    );
  }).join('');
  return '<div class="small-multiples">' + cards + '</div>';
}

function renderRadarSvg(axes) {
  // axes: [{ name, score (0-5) }]
  if (!axes || axes.length < 3) return '';
  // v7.6.1 fix: widen the SVG from 280 to 380 and r from 105 to 125 to give
  // labels more room. Use text-anchor based on horizontal position to keep
  // bottom labels from overlapping each other at 6+ axes.
  const size = 380, cx = size / 2, cy = size / 2, r = 125;
  const n = axes.length;
  const axisRows = axes.map(function (a) {
    return '<div class="radar__score-row"><span>' + escapeHtml(a.name) + '</span><strong>' + escapeHtml(String(a.score || 0)) + '/5</strong></div>';
  }).join('');
  const angle = function (i) { return -Math.PI / 2 + (i * 2 * Math.PI / n); };
  const labelHtml = axes.map(function (a, i) {
    const ang = angle(i);
    const lx = cx + Math.cos(ang) * (r + 28);
    const ly = cy + Math.sin(ang) * (r + 28);
    // Pick text-anchor based on position: left/right anchors flip.
    const dx = Math.cos(ang);
    const anchor = Math.abs(dx) < 0.2 ? 'middle' : (dx > 0 ? 'start' : 'end');
    return '<text class="radar__label" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '" text-anchor="' + anchor + '" dominant-baseline="middle">' + escapeHtml(a.name) + '</text>';
  }).join('');
  const grids = [1, 2, 3, 4, 5].map(function (k) {
    const rk = (r * k) / 5;
    const pts = axes.map(function (a, i) {
      const ang = angle(i);
      return (cx + Math.cos(ang) * rk).toFixed(1) + ',' + (cy + Math.sin(ang) * rk).toFixed(1);
    }).join(' ');
    return '<polygon class="radar__grid-line" points="' + pts + '" fill="none" stroke-opacity="' + (0.15 + k * 0.05) + '"/>';
  }).join('');
  const pts = axes.map(function (a, i) {
    const ang = angle(i);
    const sc = Math.max(0, Math.min(5, Number(a.score) || 0));
    const rs = (r * sc) / 5;
    return (cx + Math.cos(ang) * rs).toFixed(1) + ',' + (cy + Math.sin(ang) * rs).toFixed(1);
  }).join(' ');
  return (
    '<div class="radar">' +
      '<div class="radar__chart">' +
        '<svg class="radar__svg" viewBox="0 0 ' + size + ' ' + size + '" width="100%" height="' + size + '">' +
          grids + labelHtml +
          '<polygon class="radar__series" points="' + pts + '" fill-opacity="0.25" stroke-width="2"/>' +
        '</svg>' +
      '</div>' +
      '<div class="radar__scores">' + axisRows + '</div>' +
    '</div>'
  );
}

// ============================================================
// TIER 3 SPESIALKOMPONENTER — DS-helpers (v7.6.0 fase 5a-d).
// ============================================================

/**
 * Render tfa-flow + tfa-leg + tfa-arrow for et lethal trifecta-funn.
 * Used on scan + deep-scan reports when findings contain
 * en trifecta-pattern (f.eks. SCN-002 "Lethal trifecta: [Bash, Read, WebFetch]").
 * Synthesiserer 3-leddet kjede: untrusted-input → sensitive-access → exfil-sink.
 */
function renderToxicFlow(findings) {
  if (!findings || !findings.length) return '';
  const trifectaFinding = findings.find(function (f) {
    const desc = String(f.description || '');
    const cat = String(f.category || '');
    const owasp = String(f.owasp || '');
    return /trifecta/i.test(desc) || /trifecta/i.test(cat) ||
           /excessive\s*agency/i.test(cat) ||
           /ASI01/i.test(owasp);
  });
  if (!trifectaFinding) return '';
  const sev = String(trifectaFinding.severity || 'critical').toLowerCase();
  const verdictMap = { critical: 'BLOCK', high: 'BLOCK', medium: 'WARN', low: 'ALLOW' };
  const verdict = verdictMap[sev] || 'BLOCK';
  const fileLine = trifectaFinding.file
    ? trifectaFinding.file + (trifectaFinding.line ? ':' + trifectaFinding.line : '')
    : 'agent definition';
  // Default trifecta-bensin: WebFetch + Read + Bash. Override hvis description nevner andre.
  const desc = String(trifectaFinding.description || '');
  const m = desc.match(/\[([^\]]+)\]/);
  let tools = ['WebFetch', 'Read', 'Bash'];
  if (m) {
    const parsed = m[1].split(',').map(function (s) { return s.trim(); }).filter(Boolean);
    if (parsed.length === 3) tools = parsed;
  }
  const legs = [
    { label: 'Untrusted input', name: tools[0], source: fileLine, mit: 'unmitigated', mitText: 'Ingen pre-prompt-inject-scan eller post-mcp-verify guard' },
    { label: 'Sensitive access', name: tools[1], source: '.env / credentials / git-history', mit: 'unmitigated', mitText: 'No pre-write-pathguard on path' },
    { label: 'Exfil sink', name: tools[2], source: 'curl / fetch til ekstern host', mit: 'unmitigated', mitText: 'Ingen post-session-guard trifecta-deteksjon' }
  ];
  const legHtml = function (leg) {
    return (
      '<button class="tfa-leg" type="button" data-severity="' + escapeAttr(sev) + '" aria-label="' + escapeAttr(leg.label + ': ' + leg.name) + '">' +
        '<span class="tfa-leg__label">' + escapeHtml(leg.label) + '</span>' +
        '<span class="tfa-leg__name">' + escapeHtml(leg.name) + '</span>' +
        '<span class="tfa-leg__source">' + escapeHtml(leg.source) + '</span>' +
        '<span class="tfa-leg__status" data-mit="' + escapeAttr(leg.mit) + '">' + escapeHtml(leg.mitText) + '</span>' +
      '</button>'
    );
  };
  const arrowHtml = '<div class="tfa-arrow" data-severity="' + escapeAttr(sev) + '" aria-hidden="true"><div class="tfa-arrow__line"></div></div>';
  return (
    '<section class="report-meta">' +
      '<h4>Toxic flow — Lethal trifecta-kjede</h4>' +
      '<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Den fulle 3-leddete kjeden som overskrider Rule of Two. Hver leg er umitigert — ingen hook bryter kjeden.</p>' +
      '<div class="tfa-flow" role="group" aria-label="Lethal trifecta-kjede">' +
        '<div class="tfa-flow__verdict" data-verdict="' + escapeAttr(verdict) + '">' + escapeHtml(verdict) + '</div>' +
        legHtml(legs[0]) + arrowHtml + legHtml(legs[1]) + arrowHtml + legHtml(legs[2]) +
      '</div>' +
    '</section>'
  );
}

/**
 * Render mat-ladder + mat-step for posture-modenhet.
 * Mapper antall PASS-kategorier til 5 modenhetstrinn (Initial → Optimized).
 */
function renderMatLadder(categories, postureScore, postureApplicable) {
  if (!categories || !categories.length) return '';
  const passCount = postureScore != null
    ? Number(postureScore)
    : categories.filter(function (c) { return c.status === 'PASS'; }).length;
  const total = postureApplicable != null
    ? Number(postureApplicable)
    : categories.filter(function (c) { return c.status !== 'N-A' && c.status !== 'N/A'; }).length;
  const pct = total > 0 ? Math.round((passCount / total) * 100) : 0;
  // 5 maturity steps — thresholds based on % PASS
  const steps = [
    { num: 1, name: 'Initial', threshold: 0,  desc: 'Bare bones — no hooks or minimal posture.' },
    { num: 2, name: 'Aware', threshold: 25, desc: 'Posture scanning is active and the risks are known.' },
    { num: 3, name: 'Defensive', threshold: 50, desc: 'Hooks engaged on critical surfaces (PreToolUse, UserPromptSubmit).' },
    { num: 4, name: 'Mature', threshold: 75, desc: 'Most of the 16 categories covered; trifecta detection on.' },
    { num: 5, name: 'Optimized', threshold: 95, desc: 'Full coverage; A-grade on posture; active monitoring.' }
  ];
  const currentIdx = steps.reduce(function (acc, s, i) {
    return pct >= s.threshold ? i : acc;
  }, 0);
  const stepHtml = steps.map(function (s, i) {
    const state = i < currentIdx ? 'completed' : i === currentIdx ? 'current' : 'future';
    const icon = state === 'completed' ? '✓' : String(s.num);
    const pillCls = state === 'current' ? ' mat-step__pill mat-step__pill--current' :
                    state === 'completed' ? ' mat-step__pill mat-step__pill--complete' : '';
    const pillText = state === 'current' ? 'You are here' : state === 'completed' ? 'Reached' : '';
    const pill = pillText ? '<span class="' + pillCls.trim() + '">' + escapeHtml(pillText) + '</span>' : '';
    const progress = state === 'current' ? (
      '<div class="mat-step__progress">' +
        '<div class="mat-step__progress-bar"><div class="mat-step__progress-fill" style="width: ' + pct + '%"></div></div>' +
        '<span>' + passCount + ' / ' + total + ' kategorier</span>' +
      '</div>'
    ) : '';
    return (
      '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
        '<div class="mat-step__icon" aria-hidden="true">' + escapeHtml(icon) + '</div>' +
        '<div>' +
          '<div class="mat-step__name">' + escapeHtml(s.name) + pill + '</div>' +
          '<div class="mat-step__desc">' + escapeHtml(s.desc) + '</div>' +
          progress +
        '</div>' +
      '</div>'
    );
  }).join('');
  return (
    '<section class="report-meta">' +
      '<h4>Modenhetsstige — posture-progresjon</h4>' +
      '<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">A posture score of ' + passCount + ' of ' + total + ' categories (' + pct + '%) places this project at step ' + (currentIdx + 1) + ' of 5.</p>' +
      '<div class="mat-ladder" role="list" aria-label="Posture-modenhet over 5 trinn">' + stepHtml + '</div>' +
    '</section>'
  );
}

/**
 * Render suppressed-group fra v7.1.1 narrative-audit.
 * Parser executive_summary-tekst for "Suppressed signals: N (reason1: count examples, ...)"
 * eller bruker data.narrative_audit.suppressed_findings hvis strukturert.
 */
function renderSuppressedGroup(data) {
  if (!data) return '';
  const audit = data.narrative_audit || {};
  const sf = audit.suppressed_findings || {};
  let groups = [];
  let totalCount = 0;
  if (sf.by_category && typeof sf.by_category === 'object') {
    totalCount = Number(sf.count || 0);
    groups = Object.keys(sf.by_category).map(function (k) {
      return { reason: k, count: Number(sf.by_category[k]) || 0, example: '' };
    });
  } else {
    // Fall back: parse fra executive_summary
    const summary = String(data.executive_summary || '');
    const m = summary.match(/Suppressed signals:\s*\*?\*?\s*(\d+)\s*\(([^)]+)\)/i);
    if (!m) return '';
    totalCount = Number(m[1]) || 0;
    groups = m[2].split(',').map(function (part) {
      const seg = part.trim();
      const colonIdx = seg.indexOf(':');
      if (colonIdx < 0) return { reason: seg, count: 1, example: '' };
      const reason = seg.slice(0, colonIdx).trim();
      const rest = seg.slice(colonIdx + 1).trim();
      const cm = rest.match(/^(\d+)\s+(.*)$/);
      if (cm) {
        return { reason: reason, count: Number(cm[1]) || 1, example: cm[2].trim() };
      }
      return { reason: reason, count: 1, example: rest };
    });
  }
  if (!groups.length) return '';
  const groupsHtml = groups.map(function (g) {
    const example = g.example ? (
      '<div class="suppressed-group__examples">' +
        '<span class="suppressed-group__example">' + escapeHtml(g.example) + '</span>' +
      '</div>'
    ) : '';
    return (
      '<div class="suppressed-group">' +
        '<div class="suppressed-group__head">' +
          '<span class="suppressed-group__reason">' + escapeHtml(g.reason) + '</span>' +
          '<span class="suppressed-group__count">' + g.count + ' ' + (g.count === 1 ? 'forekomst' : 'forekomster') + '</span>' +
        '</div>' +
        example +
      '</div>'
    );
  }).join('');
  return (
    '<section class="report-meta">' +
      '<h4>Narrative audit — supprimerte signaler</h4>' +
      '<p class="suppressed-group__desc">' + totalCount + ' signals were suppressed pre-report (v7.1.1 narrative_audit). These are not false-positives walked back in prose — they were auto-suppressed before classification.</p>' +
      groupsHtml +
    '</section>'
  );
}

/**
 * Render codepoint-reveal + cp-tag for Unicode-steganografi (UNI-funn).
 * Used on mcp-inspect reports — swaps a plain table for a side-by-side
 * "synlig vs. decoded codepoint"-visning per tool.
 */
function renderCodepointReveal(codepoints) {
  if (!codepoints || !codepoints.length) return '';
  const tagFor = function (code) {
    // U+200B/200C/200D/FEFF = zero-width
    if (/U\+(200[B-D]|FEFF|2060|180E)/i.test(code)) return 'cp-zw';
    // U+202E/202D/2066-2069 = bidi/RTL
    if (/U\+(202[ADE]|206[6-9])/i.test(code)) return 'cp-bidi';
    // Other = generic cp-tag (warning class)
    return 'cp-tag';
  };
  const blocks = codepoints.map(function (c) {
    const risk = String(c.risk || '').trim();
    const sev = /high/i.test(risk) ? 'critical' : /medium/i.test(risk) ? 'medium' : 'low';
    const isClean = /clean|—|^-$/i.test(c.codepoints || '') || risk === '—' || risk === '-';
    const cps = String(c.codepoints || '');
    // Highlight U+XXXX patterns
    const highlighted = cps.replace(/U\+[0-9A-Fa-f]{4,6}/g, function (m) {
      return '<span class="' + tagFor(m) + '">' + m + '</span>';
    });
    const headRisk = isClean
      ? '<span style="font-size: 11px; color: var(--color-state-success);">Ren — ingen non-ASCII</span>'
      : '<span style="font-size: 11px; font-weight: var(--font-weight-semibold); color: var(--color-severity-' + sev + ');">' + escapeHtml(risk) + ' risk</span>';
    const visibleCol = isClean
      ? '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + '</div>'
      : '<div class="codepoint-reveal__source">' + escapeHtml(c.tool || '—') + ' <span style="opacity: 0.6;">(rendert visuelt)</span></div>';
    const decodedCol = isClean
      ? '<div class="codepoint-reveal__decoded">(ingen suspekte codepoints)</div>'
      : '<div class="codepoint-reveal__decoded">' + highlighted + '</div>';
    return (
      '<div class="codepoint-reveal">' +
        '<div class="codepoint-reveal__head">' +
          '<strong>' + escapeHtml(c.server || '—') + ' · <code>' + escapeHtml(c.tool || '—') + '</code></strong>' +
          headRisk +
        '</div>' +
        '<div class="codepoint-reveal__body">' +
          '<div class="codepoint-reveal__col">' +
            '<span class="codepoint-reveal__col-label">Synlig (rendret tekst)</span>' +
            visibleCol +
          '</div>' +
          '<div class="codepoint-reveal__col">' +
            '<span class="codepoint-reveal__col-label">Decoded (codepoints)</span>' +
            decodedCol +
          '</div>' +
        '</div>' +
      '</div>'
    );
  }).join('');
  return (
    '<section class="report-meta">' +
      '<h4>Codepoint-reveal — Unicode-steganografi</h4>' +
      '<p style="font-size: var(--font-size-sm); opacity: 0.78; margin: 0 0 var(--space-3);">Tools med non-ASCII codepoints i deskripsjoner — zero-width / homoglyph / bidi-override. Side-ved-side: synlig form vs. dekoded codepoints.</p>' +
      '<div style="display: flex; flex-direction: column; gap: var(--space-3);">' + blocks + '</div>' +
    '</section>'
  );
}

/**
 * Render top-risks + top-risk for rangert top-funn-listing.
 * Takes the N (default 5) highest-severity findings and
 * viser dem som ordnet liste. Bruker `.top-risks` / `.top-risk` med
 * `data-severity` for severity-tinted left-border per DS Tier 3-supplement.
 * Returnerer tom streng hvis ingen findings (eller kun info-funn).
 */
function renderTopRisks(findings, n) {
  if (!findings || !findings.length) return '';
  const sevOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
  const max = typeof n === 'number' && n > 0 ? n : 5;
  // Filtrer ut info-only — top-risks viser reelle risker, ikke observability-noise
  const filtered = findings.filter(function (f) {
    return (f.severity || 'info').toLowerCase() !== 'info';
  });
  if (!filtered.length) return '';
  const sorted = filtered.slice().sort(function (a, b) {
    return (sevOrder[a.severity] || 9) - (sevOrder[b.severity] || 9);
  });
  const top = sorted.slice(0, max);
  const items = top.map(function (f, idx) {
    const sev = String(f.severity || 'info').toLowerCase();
    const sevLabel = sev.toUpperCase();
    const meta = [
      f.file ? f.file + (f.line ? ':' + f.line : '') : '',
      f.id || '',
      f.owasp || ''
    ].filter(Boolean).join(' · ');
    const title = f.description || f.title || '—';
    return (
      '<li class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
        '<div class="top-risk__rank">' + (idx + 1) + '</div>' +
        '<div class="top-risk__desc">' +
          '<div>' + escapeHtml(title) + '</div>' +
          (meta ? '<div style="font-family: var(--font-family-mono); font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px;">' + escapeHtml(meta) + '</div>' : '') +
        '</div>' +
        '<span class="top-risk__score" data-severity="' + escapeAttr(sev) + '">' + escapeHtml(sevLabel) + '</span>' +
      '</li>'
    );
  }).join('');
  return (
    '<section class="report-meta">' +
      '<h4 class="top-risks__heading">Top ' + top.length + ' risks</h4>' +
      '<ol class="top-risks">' + items + '</ol>' +
    '</section>'
  );
}

// ============================================================
// 10 RENDERERS — one per high-priority command.
// ============================================================
function renderScan(data, slot) {
  const meterHtml = renderRiskMeter(data.risk_score, data.riskBand);
  const suppressedHtml = renderSuppressedGroup(data);
  const toxicHtml = renderToxicFlow(data.findings || []);
  const owaspHtml = (data.owasp && data.owasp.length) ? (
    '<section class="report-meta"><h4>OWASP-kategorier</h4>' +
      '<table class="report-table"><thead><tr><th>Kategori</th><th>Funn</th><th>Maks severity</th><th>Skannere</th></tr></thead><tbody>' +
      data.owasp.map(function (o) {
        return '<tr><td>' + escapeHtml(o.category) + '</td><td>' + o.findings + '</td><td>' + escapeHtml(o.max_severity) + '</td><td>' + escapeHtml(o.scanners) + '</td></tr>';
      }).join('') +
      '</tbody></table>' +
    '</section>'
  ) : '';
  const supplyHtml = (data.supply_chain && data.supply_chain.length) ? (
    '<section class="report-meta"><h4>Supply chain</h4>' +
      '<table class="report-table"><thead><tr><th>Komponent</th><th>Type</th><th>Kilde</th><th>Trust</th><th>Notater</th></tr></thead><tbody>' +
      data.supply_chain.map(function (s) {
        return '<tr><td>' + escapeHtml(s.component) + '</td><td>' + escapeHtml(s.type) + '</td><td>' + escapeHtml(s.source) + '</td><td>' + escapeHtml(s.trust) + '</td><td>' + escapeHtml(s.notes) + '</td></tr>';
      }).join('') +
      '</tbody></table>' +
    '</section>'
  ) : '';
  const topRisksHtml = renderTopRisks(data.findings || [], 5);
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = meterHtml + suppressedHtml + toxicHtml + topRisksHtml + owaspHtml + supplyHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'SKANNING',
    title: data.title || 'Security Scan',
    lede: data.lede || (data.executive_summary ? data.executive_summary.split('\n')[0].slice(0, 220) : 'Skann av skills, MCP-konfig, kataloger eller GitHub-URL.'),
    verdict: data.verdict || inferVerdict(data, 'risk-score-meter'),
    keyStats: data.keyStats || inferKeyStats(data, 'risk-score-meter')
  }, body);
}
RENDERERS.renderScan = renderScan;

function renderDeepScan(data, slot) {
  // Per-scanner small-multiples
  const sm = (data.scanners || []).map(function (s) {
    const okStatus = /ok/i.test(s.status || '') ? 'ok' : (s.status || 'unknown');
    const grade = (s.findings === 0) ? 'A' : (s.findings <= 3) ? 'B' : (s.findings <= 8) ? 'C' : (s.findings <= 15) ? 'D' : 'F';
    return {
      name: s.tag + ' · ' + s.name,
      score: Math.max(0, 5 - Math.min(5, Math.floor((s.findings || 0) / 3))),
      max: 5,
      grade: grade,
      status: s.findings + ' funn · ' + (s.duration_ms || 0) + 'ms · ' + okStatus
    };
  });
  const smHtml = renderSmallMultiples(sm);
  // Scanner Risk Matrix-tabell
  const matrixRows = (data.scanner_matrix || []).map(function (r) {
    return '<tr><td>' + escapeHtml(r.scanner) + '</td>' +
      '<td>' + r.critical + '</td>' +
      '<td>' + r.high + '</td>' +
      '<td>' + r.medium + '</td>' +
      '<td>' + r.low + '</td>' +
      '<td>' + r.info + '</td></tr>';
  }).join('');
  const matrixHtml = matrixRows ? (
    '<section class="report-meta"><h4>Scanner Risk Matrix</h4>' +
      '<table class="report-table"><thead><tr><th>Scanner</th><th>CRIT</th><th>HIGH</th><th>MED</th><th>LOW</th><th>INFO</th></tr></thead><tbody>' +
      matrixRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : '';
  const topRisksHtml = renderTopRisks(data.findings || [], 5);
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Findings (utvalg)');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const suppressedHtml = renderSuppressedGroup(data);
  const toxicHtml = renderToxicFlow(data.findings || []);
  const body = meterHtml + suppressedHtml + toxicHtml + smHtml + matrixHtml + topRisksHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'DEEP-SCAN',
    title: data.title || 'Deterministisk deep-scan',
    lede: data.lede || '10 deterministiske Node.js-scannere, ingen LLM-invokasjon.',
    verdict: data.verdict || inferVerdict(data, 'findings-grade'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings-grade')
  }, body);
}
RENDERERS.renderDeepScan = renderDeepScan;

function renderPluginAudit(data, slot) {
  const meta = data.plugin_metadata || {};
  const metaRows = Object.keys(meta).map(function (k) {
    return '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(meta[k]) + '</td></tr>';
  }).join('');
  const metaHtml = metaRows ? '<section class="report-meta"><h4>Plugin-metadata</h4><table class="report-table"><tbody>' + metaRows + '</tbody></table></section>' : '';
  const compHtml = (data.components && data.components.length) ? (
    '<section class="report-meta"><h4>Komponenter</h4>' +
      '<table class="report-table"><thead><tr><th>Komponent</th><th>Antall</th><th>Notater</th></tr></thead><tbody>' +
      data.components.map(function (c) {
        return '<tr><td>' + escapeHtml(c.component) + '</td><td>' + c.count + '</td><td>' + escapeHtml(c.notes) + '</td></tr>';
      }).join('') +
      '</tbody></table>' +
    '</section>'
  ) : '';
  const permHtml = (data.permissions && data.permissions.length) ? (
    '<section class="report-meta"><h4>Permission-matrise</h4>' +
      '<table class="report-table"><thead><tr><th>Tool</th><th>Required by</th><th>Justified</th></tr></thead><tbody>' +
      data.permissions.map(function (p) {
        const isYes = /^yes|^ja/i.test(p.justified);
        const isNo = /^no$|^nei/i.test(p.justified);
        const cls = isYes ? 'low' : (isNo ? 'critical' : 'medium');
        return '<tr><td>' + escapeHtml(p.tool) + '</td><td>' + escapeHtml(p.required_by) + '</td><td><span class="key-stat__value" style="color: var(--color-' + cls + ')">' + escapeHtml(p.justified) + '</span></td></tr>';
      }).join('') +
      '</tbody></table>' +
    '</section>'
  ) : '';
  const trustSev = (function () {
    const t = String(data.trust_verdict_text || '').toLowerCase();
    if (/block|fail|critical|do\s*not\s*install/i.test(t)) return 'critical';
    if (/warn|caution|review|conditional/i.test(t)) return 'high';
    if (/allow|trust|verified|pass/i.test(t)) return 'positive';
    return 'medium';
  })();
  const trustHtml = data.trust_verdict_text ? (
    '<section class="recommendation-card" data-severity="' + escapeAttr(trustSev) + '">' +
      '<span class="recommendation-card__label">Trust-verdict</span>' +
      '<p class="recommendation-card__body">' + escapeHtml(data.trust_verdict_text).replace(/\n/g, '<br>') + '</p>' +
    '</section>'
  ) : '';
  const topRisksHtml = renderTopRisks(data.findings || [], 5);
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = renderRiskMeter(data.risk_score, data.riskBand) + metaHtml + compHtml + permHtml + trustHtml + topRisksHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'PLUGIN-AUDIT',
    title: data.title || 'Plugin trust-vurdering',
    lede: data.lede || 'Trust verdict based on maintainer, license, permissions, and MCP descriptions.',
    verdict: data.verdict || inferVerdict(data, 'risk-score-meter'),
    keyStats: data.keyStats || inferKeyStats(data, 'risk-score-meter')
  }, body);
}
RENDERERS.renderPluginAudit = renderPluginAudit;

function renderMcpAudit(data, slot) {
  const landRows = (data.mcp_servers || []).map(function (s) {
    return '<tr>' +
      '<td>' + escapeHtml(s.server) + '</td>' +
      '<td>' + escapeHtml(s.type) + '</td>' +
      '<td>' + escapeHtml(s.trust) + '</td>' +
      '<td>' + s.tools + '</td>' +
      '<td>' + (s.active ? '<span class="key-stat__value" style="color: var(--color-low)">aktiv</span>' : '<span class="key-stat__value" style="color: var(--color-medium)">dormant</span>') + '</td>' +
    '</tr>';
  }).join('');
  const landHtml = landRows ? (
    '<section class="report-meta"><h4>MCP-landskap</h4>' +
      '<table class="report-table"><thead><tr><th>Server</th><th>Type</th><th>Trust</th><th>Tools</th><th>Status</th></tr></thead><tbody>' + landRows + '</tbody></table>' +
    '</section>'
  ) : '';
  // Per-server som critique-cards
  const psHtml = (data.per_server && data.per_server.length) ? (
    '<div class="critique-cards">' + data.per_server.map(function (p) {
      const sev = /(verdict:.*BLOCK|verdict:.*FAIL|critical)/i.test(p.body) ? 'critical' :
                  /(verdict:.*WARNING|warn|medium|drift)/i.test(p.body) ? 'medium' :
                  'low';
      const lines = p.body.split(/\r?\n/).slice(0, 6).join(' ');
      return '<div class="critique-card" data-severity="' + escapeAttr(sev) + '">' +
        '<div class="critique-card__header">' +
          '<div class="critique-card__title">' + escapeHtml(p.name) + '</div>' +
          (p.note ? '<div class="critique-card__meta"><span class="critique-card__id">' + escapeHtml(p.note) + '</span></div>' : '') +
        '</div>' +
        '<div class="critique-card__recommendation">' + escapeHtml(lines.slice(0, 360)) + (lines.length > 360 ? '…' : '') + '</div>' +
      '</div>';
    }).join('') + '</div>'
  ) : '';
  // Keep / Review / Remove kanban
  const buckets = data.buckets || { keep: [], review: [], remove: [] };
  const cardFor = function (bucket, label) {
    const items = buckets[bucket] || [];
    const cards = items.length ? items.map(function (it) {
      return '<div class="kanban-card">' +
        '<div class="kanban-card__name">' + escapeHtml(it.server) + '</div>' +
        (it.reason ? '<div class="kanban-card__meta">' + escapeHtml(it.reason) + '</div>' : '') +
      '</div>';
    }).join('') : '<div class="kanban-col__empty">Ingen</div>';
    return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
      '<div class="kanban-col__head">' +
        '<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
        '<span class="kanban-col__count">' + items.length + '</span>' +
      '</div>' + cards + '</div>';
  };
  const kanbanHtml = '<div class="kanban-board">' +
    cardFor('keep', 'Keep') +
    cardFor('review', 'Review') +
    cardFor('remove', 'Remove') +
  '</div>';
  const findingsHtml = renderFindingsBlock(data.findings || [], 'MCP-funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = landHtml + psHtml + kanbanHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'MCP-AUDIT',
    title: data.title || 'MCP-konfig audit',
    lede: data.lede || 'Permissions, trust, and description drift across installed MCP servers.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderMcpAudit = renderMcpAudit;

function renderIdeScan(data, slot) {
  const covRows = (data.coverage || []).map(function (c) {
    return '<tr><td>' + escapeHtml(c.ide) + '</td><td>' + c.extensions + '</td><td>' + c.findings + '</td></tr>';
  }).join('');
  const covHtml = covRows ? (
    '<section class="report-meta"><h4>Scan-dekning</h4>' +
      '<table class="report-table"><thead><tr><th>IDE</th><th>Extensions</th><th>Funn</th></tr></thead><tbody>' + covRows + '</tbody></table>' +
    '</section>'
  ) : '';
  // Findings — bruk renderFindingsBlock men med extension+ide som meta
  const fs = (data.findings || []).map(function (f) {
    return Object.assign({}, f, {
      file: f.extension || f.file || '',
      category: f.ide || ''
    });
  });
  const findingsHtml = renderFindingsBlock(fs, 'IDE-extension funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = covHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'IDE-SCAN',
    title: data.title || 'IDE-extension scan',
    lede: data.lede || 'VS Code + JetBrains supply-chain-sjekk, blocklist + typosquat + obfuskering.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderIdeScan = renderIdeScan;

function renderPosture(data, slot) {
  // Small-multiples per kategori
  const items = (data.categories || []).filter(function (c) {
    return c.status !== 'N-A' && c.status !== 'N/A';
  }).map(function (c) {
    const score = c.status === 'PASS' ? 5 : c.status === 'PARTIAL' ? 3 : c.status === 'FAIL' ? 1 : 0;
    const grade = c.status === 'PASS' ? 'A' : c.status === 'PARTIAL' ? 'C' : c.status === 'FAIL' ? 'F' : '';
    return {
      name: c.num + '. ' + c.name,
      score: score,
      max: 5,
      grade: grade,
      status: c.status + (c.findings ? ' · ' + c.findings + ' funn' : '')
    };
  });
  const smHtml = renderSmallMultiples(items);
  const ladderHtml = renderMatLadder(data.categories || [], data.posture_score, data.posture_applicable);
  // Quick wins
  const quickHtml = (data.quick_wins && data.quick_wins.length) ? (
    '<section class="recommendation-card" data-severity="positive">' +
      '<span class="recommendation-card__label">Quick wins</span>' +
      '<ol class="recommendation-card__body">' +
        data.quick_wins.map(function (w) { return '<li>' + escapeHtml(w) + '</li>'; }).join('') +
      '</ol>' +
    '</section>'
  ) : '';
  const topRisksHtml = renderTopRisks(data.findings || [], 5);
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Top findings');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const overall = data.posture_score != null ? (
    '<section class="report-meta"><h4>Overall score</h4><p><strong>' + data.posture_score + ' / ' + (data.posture_applicable || '?') + ' kategorier dekket</strong> — Grade ' + escapeHtml(data.grade || '?') + '.</p></section>'
  ) : '';
  const body = overall + ladderHtml + smHtml + quickHtml + topRisksHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'POSTURE',
    title: data.title || 'Security posture',
    lede: data.lede || 'Rask scorecard, deterministisk scanner, <2s.',
    verdict: data.verdict || inferVerdict(data, 'posture-cards'),
    keyStats: data.keyStats || inferKeyStats(data, 'posture-cards')
  }, body);
}
RENDERERS.renderPosture = renderPosture;

function renderAudit(data, slot) {
  const radarHtml = renderRadarSvg(data.radar_axes || []);
  // Category Assessment som expansion-kort
  const catHtml = (data.categories && data.categories.length) ? (
    '<section class="report-meta"><h4>Kategori-vurdering</h4>' +
      '<div class="findings__items">' + data.categories.map(function (c) {
        const sev = c.status === 'FAIL' ? 'critical' : c.status === 'PARTIAL' ? 'medium' : c.status === 'PASS' ? 'low' : 'info';
        const sevClass = 'card--severity-' + sev;
        return '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
          '<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
          '<div>' +
            '<div class="findings__item-id">Kat. ' + c.num + '</div>' +
            '<div class="findings__item-title">' + escapeHtml(c.name) + '</div>' +
            '<div class="findings__item-meta">Status: <strong>' + escapeHtml(c.status || '—') + '</strong></div>' +
          '</div>' +
        '</div>';
      }).join('') + '</div>' +
    '</section>'
  ) : '';
  // Action Plan tre-tier
  const tierHtml = function (tier, label, sev) {
    const items = (data.action_plan && data.action_plan[tier]) || [];
    if (!items.length) return '';
    return '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
      '<span class="recommendation-card__label">' + escapeHtml(label) + '</span>' +
      '<ol class="recommendation-card__body">' + items.map(function (a) { return '<li>' + escapeHtml(a) + '</li>'; }).join('') + '</ol>' +
    '</section>';
  };
  const actionHtml = tierHtml('immediate', 'Immediate', 'critical') + tierHtml('high', 'High priority', 'high') + tierHtml('medium', 'Medium priority', 'medium');
  const meterHtml = (data.risk_score != null) ? renderRiskMeter(data.risk_score, data.riskBand) : '';
  const topRisksHtml = renderTopRisks(data.findings || [], 5);
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn');
  const body = meterHtml + radarHtml + catHtml + actionHtml + topRisksHtml + findingsHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'AUDIT',
    title: data.title || 'Full security audit',
    lede: data.lede || 'OWASP LLM Top 10-vurdering, A-F grading, action plan.',
    verdict: data.verdict || inferVerdict(data, 'findings-grade'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings-grade')
  }, body);
}
RENDERERS.renderAudit = renderAudit;

function renderDashboard(data, slot) {
  // Fleet-grid med fleet-tile per prosjekt
  const projects = data.projects || [];
  const sevForGrade = function (g) {
    const u = String(g || '').toUpperCase();
    if (u === 'A') return 'low';
    if (u === 'B') return 'low';
    if (u === 'C') return 'medium';
    if (u === 'D') return 'high';
    if (u === 'F') return 'critical';
    return 'info';
  };
  const tiles = projects.length ? projects.map(function (p) {
    const trend = (data.trends || []).find(function (t) { return t.name === p.name; });
    const trendCls = trend ? ('fleet-tile__trend--' + trend.trend) : 'fleet-tile__trend--stable';
    const fillPct = Math.max(0, Math.min(100, p.risk));
    return (
      '<div class="fleet-tile" data-severity="' + escapeAttr(sevForGrade(p.grade)) + '">' +
        '<div class="fleet-tile__row">' +
          '<span class="fleet-tile__name" title="' + escapeAttr(p.name) + '">' + escapeHtml(p.name) + '</span>' +
          '<span class="fleet-tile__grade" data-grade="' + escapeAttr(p.grade || '') + '">' + escapeHtml(p.grade || '?') + '</span>' +
        '</div>' +
        '<div class="fleet-tile__meter"><div class="fleet-tile__meter-fill" style="width: ' + fillPct + '%"></div></div>' +
        '<div class="fleet-tile__meta">' +
          '<span>Risk ' + p.risk + ' · ' + p.findings + ' funn</span>' +
          (trend ? '<span class="' + trendCls + '">' + escapeHtml(trend.d_risk) + '</span>' : '') +
        '</div>' +
        (p.worst_category ? '<div class="fleet-tile__meta"><span class="fleet-tile__chip">Verst: ' + escapeHtml(p.worst_category) + '</span></div>' : '') +
      '</div>'
    );
  }).join('') : '';
  const gridHtml = tiles ? '<div class="fleet-grid">' + tiles + '</div>' : renderEmptyState('Ingen prosjekter funnet.');
  // Errors
  const errorsHtml = (data.errors && data.errors.length) ? (
    '<section class="report-meta"><h4>Errors</h4>' +
      '<table class="report-table"><thead><tr><th>Prosjekt</th><th>Feil</th></tr></thead><tbody>' +
      data.errors.map(function (e) { return '<tr><td>' + escapeHtml(e.project) + '</td><td>' + escapeHtml(e.error) + '</td></tr>'; }).join('') +
      '</tbody></table>' +
    '</section>'
  ) : '';
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = gridHtml + errorsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'DASHBOARD',
    title: data.title || 'Cross-project dashboard',
    lede: data.lede || 'Maskin-grade = svakeste lenke. Aggregert posture-skann per prosjekt.',
    verdict: data.verdict || inferVerdict(data, 'dashboard-fleet'),
    keyStats: data.keyStats || inferKeyStats(data, 'dashboard-fleet')
  }, body);
}
RENDERERS.renderDashboard = renderDashboard;

function renderHarden(data, slot) {
  const recs = data.recommendations || [];
  // Diff-blokker per recommendation — DS Tier 3 recommendation-card med data-severity (v7.6.0 fase 5f).
  // CREATE → positive (ny grade A-fil), APPEND → medium (eksisterende fil utvides),
  // MERGE → low (allerede satt, kun normalisering), SKIP → low (ingen handling).
  const diffHtml = recs.map(function (r, idx) {
    const isCreate = /create/i.test(r.action);
    const isAppend = /append/i.test(r.action);
    const isMerge = /merge/i.test(r.action);
    const isNone = /none|skip/i.test(r.action);
    const actionLabel = isCreate ? 'CREATE' : isAppend ? 'APPEND' : isMerge ? 'MERGE' : 'SKIP';
    const sev = mapSeverityToCardLevel(actionLabel);
    return (
      '<section class="recommendation-card" data-severity="' + escapeAttr(sev) + '">' +
        '<span class="recommendation-card__label">' + actionLabel + ' · ' + escapeHtml(String(r.num)) + '. ' + escapeHtml(r.category) + '</span>' +
        '<div class="recommendation-card__body">' +
          '<div><code>' + escapeHtml(r.file) + '</code></div>' +
          (r.content_preview ? '<pre style="margin: var(--space-2) 0; font-size: var(--font-size-sm); white-space: pre-wrap; opacity: ' + (isNone ? '0.6' : '0.9') + '">' + escapeHtml(r.content_preview).slice(0, 600) + (r.content_preview.length > 600 ? '…' : '') + '</pre>' : '') +
        '</div>' +
      '</section>'
    );
  }).join('');
  // Diff summary footer
  const summaryRows = (data.diff_summary || []).map(function (d) {
    return '<div class="diff__summary-item"><span>' + escapeHtml(d.file) + '</span><span class="diff__summary-count">' + escapeHtml(d.action) + ' · ' + escapeHtml(d.lines) + '</span></div>';
  }).join('');
  const summaryHtml = summaryRows ? '<div class="diff__summary">' + summaryRows + '</div>' : '';
  const introSev = (function () {
    const g = String(data.current_grade || '?').toUpperCase();
    if (g === 'F' || g === 'D') return 'critical';
    if (g === 'C') return 'high';
    if (g === 'B') return 'medium';
    if (g === 'A') return 'positive';
    return 'medium';
  })();
  const intro = (
    '<section class="recommendation-card" data-severity="' + escapeAttr(introSev) + '">' +
      '<span class="recommendation-card__label">Snapshot · grade ' + escapeHtml(data.current_grade || '?') + '</span>' +
      '<p class="recommendation-card__body">Prosjekt-type: <strong>' + escapeHtml(data.project_type || '?') + '</strong> · ' + data.actionable + '/' + data.total + ' anbefalinger · Modus: <em>' + escapeHtml(data.mode || 'dry-run') + '</em></p>' +
    '</section>'
  );
  const body = intro + (diffHtml || renderEmptyState('Ingen anbefalinger.')) + summaryHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'HARDEN',
    title: data.title || 'Grade A reference config',
    lede: data.lede || 'Diff preview of settings.json, CLAUDE.md, and .gitignore changes.',
    verdict: data.verdict || inferVerdict(data, 'diff-report'),
    keyStats: data.keyStats || [
      { label: 'CURRENT GRADE', value: String(data.current_grade || '?') },
      { label: 'ACTIONS', value: data.actionable + '/' + data.total },
      { label: 'MODE', value: data.mode || 'dry-run' }
    ]
  }, body);
}
RENDERERS.renderHarden = renderHarden;

function renderRedTeam(data, slot) {
  const meterHtml = renderRiskMeter(100 - (data.defense_score || 0), data.riskBand);
  // Per-category small-multiples
  const cats = (data.categories || []).map(function (c) {
    const total = (c.pass || 0) + (c.fail || 0);
    const score = total ? Math.round((c.pass / total) * 5) : 0;
    const grade = total === 0 ? '?' : c.fail === 0 ? 'A' : c.fail <= 1 ? 'B' : c.fail <= 3 ? 'C' : 'D';
    return {
      name: c.category,
      score: score,
      max: 5,
      grade: grade,
      status: c.pass + ' pass · ' + c.fail + ' fail'
    };
  });
  const smHtml = renderSmallMultiples(cats);
  // Failed scenarios som findings
  const scnFindings = (data.scenarios || []).map(function (s) {
    return {
      id: s.id,
      severity: s.severity,
      category: s.category,
      description: s.payload_class + ' — ' + s.reason,
      owasp: ''
    };
  });
  const findingsHtml = renderFindingsBlock(scnFindings, 'Failed scenarios');
  // History
  const historyRows = (data.history || []).map(function (h) {
    return '<tr><td>' + escapeHtml(h.run) + '</td><td>' + escapeHtml(h.date) + '</td><td>' + h.defense_score + '%</td><td>' + escapeHtml(h.delta) + '</td></tr>';
  }).join('');
  const historyHtml = historyRows ? (
    '<section class="report-meta"><h4>Defense score-historikk</h4>' +
      '<table class="report-table"><thead><tr><th>Run</th><th>Dato</th><th>Score</th><th>Δ</th></tr></thead><tbody>' + historyRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = meterHtml + smHtml + findingsHtml + historyHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'RED-TEAM',
    title: data.title || 'Attack-simulasjon',
    lede: data.lede || (data.adaptive ? 'Adaptive mode aktiv (mutation-based evasion).' : 'Statisk mode — 64 deterministiske scenarios.'),
    verdict: data.verdict || inferVerdict(data, 'red-team-results'),
    keyStats: data.keyStats || inferKeyStats(data, 'red-team-results')
  }, body);
}
RENDERERS.renderRedTeam = renderRedTeam;

// ============================================================
// PHASE 3: 8 RENDERERS — one per remaining command.
// ============================================================
function renderMcpInspect(data, slot) {
  const invRows = (data.server_inventory || []).map(function (s) {
    return '<tr>' +
      '<td>' + escapeHtml(s.server) + '</td>' +
      '<td>' + escapeHtml(s.transport) + '</td>' +
      '<td>' + s.tools + '</td>' +
      '<td>' + escapeHtml(s.status) + '</td>' +
      '<td>' + (s.connected ? '<span class="key-stat__value" style="color: var(--color-low)">ja</span>' : '<span class="key-stat__value" style="color: var(--color-medium)">nei</span>') + '</td>' +
    '</tr>';
  }).join('');
  const invHtml = invRows ? (
    '<section class="report-meta"><h4>Server-inventar</h4>' +
      '<table class="report-table"><thead><tr><th>Server</th><th>Transport</th><th>Tools</th><th>Status</th><th>Connected</th></tr></thead><tbody>' + invRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const cpHtml = renderCodepointReveal(data.codepoints || []);
  const fs = (data.findings || []).map(function (f) {
    return Object.assign({}, f, {
      file: f.server || f.file || '',
      category: f.category || ''
    });
  });
  const findingsHtml = renderFindingsBlock(fs, 'MCP-inspect funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = invHtml + cpHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'MCP-INSPECT',
    title: data.title || 'MCP live-inspect',
    lede: data.lede || 'Runtime tool-deskripsjoner — drift, tool shadowing, codepoint reveal.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderMcpInspect = renderMcpInspect;

function renderSupplyCheck(data, slot) {
  // Ecosystem cards (small-multiples pattern)
  const ecos = (data.ecosystems || []).filter(function (e) { return Number(e.packages) > 0 || Number(e.osv_hits) > 0 || Number(e.typosquats) > 0; });
  const ecoCards = ecos.length ? '<div class="small-multiples">' + ecos.map(function (e) {
    const issues = (Number(e.osv_hits) || 0) + (Number(e.typosquats) || 0);
    const grade = issues === 0 ? 'A' : issues <= 1 ? 'B' : issues <= 3 ? 'C' : issues <= 6 ? 'D' : 'F';
    const score = Math.max(0, 5 - Math.min(5, issues));
    const fillPct = (score / 5) * 100;
    return '<div class="sm-card">' +
             '<div class="sm-card__header">' +
               '<span class="sm-card__name">' + escapeHtml(e.ecosystem) + '</span>' +
               '<span class="sm-card__grade" data-grade="' + escapeAttr(grade) + '">' + escapeHtml(grade) + '</span>' +
             '</div>' +
             '<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + fillPct.toFixed(0) + '%"></div></div>' +
             '<span class="sm-card__status">' + e.packages + ' pakker · ' + e.osv_hits + ' OSV · ' + e.typosquats + ' typosquats</span>' +
           '</div>';
  }).join('') + '</div>' : '';
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Supply-chain funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = ecoCards + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'SUPPLY-CHECK',
    title: data.title || 'Supply-chain recheck',
    lede: data.lede || 'Re-audit lockfiler mot blocklists, OSV.dev og typosquat-deteksjon.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderSupplyCheck = renderSupplyCheck;

function renderPreDeploy(data, slot) {
  const lights = data.traffic_lights || [];
  const sevForStatus = function (s) {
    const u = String(s || '').toUpperCase();
    if (u === 'PASS' || u === 'GO') return 'low';
    if (u === 'PASS-WITH-NOTES' || u === 'WARNING' || u === 'PARTIAL') return 'medium';
    if (u === 'FAIL' || u === 'BLOCK' || u === 'NO-GO') return 'critical';
    return 'info';
  };
  // v7.6.1 fix: sm-card__grade is fixed at 28×28 px (designed for one A-F letter), so
  // "PASS"/"PASS-WITH-NOTES"/"FAIL" ble kuttet til "AS"/"PASS-WITH-..."/"FA". Bytt
  // til en bredde-tilpasset status-pill via inline styling (ingen DS-klasse-endring).
  const cards = lights.map(function (l) {
    const sev = sevForStatus(l.status);
    const pillBg = sev === 'low' ? 'var(--color-severity-low-soft)'
                 : sev === 'medium' ? 'var(--color-severity-medium-soft)'
                 : sev === 'critical' ? 'var(--color-severity-critical-soft)'
                 : 'var(--color-bg-soft)';
    const pillFg = sev === 'low' ? 'var(--color-severity-low-on)'
                 : sev === 'medium' ? 'var(--color-severity-medium-on)'
                 : sev === 'critical' ? 'var(--color-severity-critical-on)'
                 : 'var(--color-text-secondary)';
    const statusPill = '<span style="font-family: var(--font-family-mono); font-size: 11px; font-weight: var(--font-weight-bold); letter-spacing: 0.04em; padding: 3px 8px; border-radius: var(--radius-sm); background: ' + pillBg + '; color: ' + pillFg + '; white-space: nowrap;">' + escapeHtml(l.status) + '</span>';
    return '<div class="sm-card" data-severity="' + escapeAttr(sev) + '" style="border-left: 3px solid var(--color-severity-' + (sev === 'low' ? 'low' : sev === 'medium' ? 'medium' : sev === 'critical' ? 'critical' : 'low') + '); padding-left: var(--space-3);">' +
             '<div class="sm-card__header">' +
               '<span class="sm-card__name">' + escapeHtml(l.category) + '</span>' +
               statusPill +
             '</div>' +
             (l.notes ? '<span class="sm-card__status">' + escapeHtml(l.notes) + '</span>' : '') +
           '</div>';
  }).join('');
  const lightsHtml = cards ? '<section class="report-meta"><h4>Traffic-light kategorier</h4><div class="small-multiples">' + cards + '</div></section>' : '';
  const condHtml = (data.conditions && data.conditions.length) ? (
    '<section class="recommendation-card" data-severity="high">' +
      '<span class="recommendation-card__label">Conditions to resolve</span>' +
      '<ol class="recommendation-card__body">' + data.conditions.map(function (c) { return '<li>' + escapeHtml(c) + '</li>'; }).join('') + '</ol>' +
    '</section>'
  ) : '';
  const apprRows = (data.approvals || []).map(function (a) {
    const isPending = /pending|—/i.test(a.approver) || !a.approver.trim();
    return '<tr><td>' + escapeHtml(a.role) + '</td><td>' + (isPending ? '<em>(venter)</em>' : escapeHtml(a.approver)) + '</td><td>' + escapeHtml(a.date || '—') + '</td><td>' + escapeHtml(a.notes) + '</td></tr>';
  }).join('');
  const apprHtml = apprRows ? (
    '<section class="report-meta"><h4>Godkjenninger</h4>' +
      '<table class="report-table"><thead><tr><th>Rolle</th><th>Godkjenner</th><th>Dato</th><th>Notater</th></tr></thead><tbody>' + apprRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Pre-deploy funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = lightsHtml + condHtml + apprHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'PRE-DEPLOY',
    title: data.title || 'Pre-deploy security checklist',
    lede: data.lede || 'Enterprise-gate + production readiness — 13 kategorier.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderPreDeploy = renderPreDeploy;

function renderDiff(data, slot) {
  const newItems = data['new'] || [];
  const resolvedItems = data.resolved || [];
  const unchangedItems = data.unchanged || [];
  const movedItems = data.moved || [];
  const gradeBadge = function (g) {
    return g ? '<span class="sm-card__grade" data-grade="' + escapeAttr(g) + '">' + escapeHtml(g) + '</span>' : '<span class="sm-card__grade" data-grade="?">?</span>';
  };
  const headerHtml = (
    '<section class="report-meta"><h4>Grade-bevegelse</h4>' +
      '<div class="pair-before-after">' +
        '<div class="pair-before-after__cell">' +
          '<span class="pair-before-after__cell-label">BASELINE ' + escapeHtml(data.baseline_date || '') + '</span>' +
          '<span class="pair-before-after__cell-value">' + gradeBadge(data.baseline_grade) + '</span>' +
        '</div>' +
        '<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
        '<div class="pair-before-after__cell">' +
          '<span class="pair-before-after__cell-label">NOW</span>' +
          '<span class="pair-before-after__cell-value">' + gradeBadge(data.current_grade) + '</span>' +
        '</div>' +
      '</div>' +
    '</section>'
  );
  const renderRowItem = function (it, action) {
    const sev = it.severity || 'info';
    const sevClass = 'card--severity-' + sev;
    const meta = [it.category, it.file, it.resolution, it.notes].filter(Boolean).join(' · ');
    const cellClass = action === 'new' ? 'diff__cell--added' :
                      action === 'resolved' ? 'diff__cell--unchanged' :
                      'diff__cell--unchanged';
    return '<div class="diff__row">' +
             '<div class="diff__cell ' + cellClass + '">' +
               '<div class="findings__item ' + sevClass + '" data-severity="' + escapeAttr(sev) + '">' +
                 '<div class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></div>' +
                 '<div>' +
                   '<div class="findings__item-id">' + escapeHtml(it.id || '—') + '</div>' +
                   '<div class="findings__item-title">' + escapeHtml(it.description || it.resolution || it.notes || '') + '</div>' +
                   (meta ? '<div class="findings__item-meta">' + escapeHtml(meta) + '</div>' : '') +
                 '</div>' +
               '</div>' +
             '</div>' +
           '</div>';
  };
  const sectionFor = function (label, items, action) {
    if (!items.length) return '';
    return '<section class="report-meta"><h4>' + escapeHtml(label) + ' (' + items.length + ')</h4>' +
             '<div class="diff">' + items.map(function (it) { return renderRowItem(it, action); }).join('') + '</div>' +
           '</section>';
  };
  const newHtml = sectionFor('Nye funn', newItems, 'new');
  const resHtml = sectionFor('Resolved findings', resolvedItems, 'resolved');
  const unchHtml = sectionFor('Uendret', unchangedItems, 'unchanged');
  const movHtml = (movedItems.length) ? sectionFor('Flyttet', movedItems.map(function (m) {
    return { id: m.id, severity: 'info', description: m.from + ' → ' + m.to };
  }), 'moved') : '';
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = headerHtml + newHtml + resHtml + unchHtml + movHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'DIFF',
    title: data.title || 'Scan diff mot baseline',
    lede: data.lede || 'Compares the current scan against the stored baseline.',
    verdict: data.verdict || inferVerdict(data, 'diff-report'),
    keyStats: data.keyStats || inferKeyStats(data, 'diff-report')
  }, body);
}
RENDERERS.renderDiff = renderDiff;

function renderWatch(data, slot) {
  const meter = data.live_meter || {};
  const meterRows = Object.keys(meter).map(function (k) {
    return '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(meter[k]) + '</td></tr>';
  }).join('');
  const meterHtml = meterRows ? (
    '<section class="report-meta"><h4>Live-meter</h4>' +
      '<table class="report-table"><tbody>' + meterRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const histRows = (data.history || []).map(function (h) {
    const isCurrent = /^current/i.test(h.run);
    return '<tr' + (isCurrent ? ' style="font-weight: 600;"' : '') + '>' +
      '<td>' + escapeHtml(h.run) + '</td>' +
      '<td>' + escapeHtml(h.time) + '</td>' +
      '<td><span class="sm-card__grade" data-grade="' + escapeAttr(h.grade || '?') + '">' + escapeHtml(h.grade || '?') + '</span></td>' +
      '<td>' + h.risk_score + '</td>' +
      '<td>' + escapeHtml(h.delta || '—') + '</td>' +
    '</tr>';
  }).join('');
  const histHtml = histRows ? (
    '<section class="report-meta"><h4>Siste runs</h4>' +
      '<table class="report-table"><thead><tr><th>Run</th><th>Tid</th><th>Grade</th><th>Risk</th><th>Δ</th></tr></thead><tbody>' + histRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Funn (siste run)');
  const notRows = (data.notify_events || []).map(function (n) {
    return '<tr><td>' + escapeHtml(n.time) + '</td><td>' + escapeHtml(n.event) + '</td><td>' + escapeHtml(n.channel) + '</td><td>' + escapeHtml(n.status) + '</td></tr>';
  }).join('');
  const notHtml = notRows ? (
    '<section class="report-meta"><h4>Notify-eventer</h4>' +
      '<table class="report-table"><thead><tr><th>Tid</th><th>Event</th><th>Channel</th><th>Status</th></tr></thead><tbody>' + notRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = meterHtml + histHtml + findingsHtml + notHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'WATCH',
    title: data.title || 'Continuous monitoring',
    lede: data.lede || 'Runs diff on a recurring interval via /loop. Notifies on new findings.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderWatch = renderWatch;

function renderRegistry(data, slot) {
  const stats = data.stats || {};
  const statsRows = Object.keys(stats).map(function (k) {
    return '<tr><td>' + escapeHtml(k.replace(/_/g, ' ')) + '</td><td>' + escapeHtml(stats[k]) + '</td></tr>';
  }).join('');
  const statsHtml = statsRows ? (
    '<section class="report-meta"><h4>Registry-stats</h4>' +
      '<table class="report-table"><tbody>' + statsRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const sigRows = (data.signatures || []).map(function (s) {
    const isBad = /known-?bad|malicious/i.test(s.status);
    const isDrift = /drift/i.test(s.status);
    const isUnknown = /unknown/i.test(s.status);
    const sev = isBad ? 'critical' : isDrift ? 'medium' : isUnknown ? 'low' : 'info';
    return '<tr>' +
      '<td>' + escapeHtml(s.skill) + '</td>' +
      '<td>' + escapeHtml(s.source) + '</td>' +
      '<td><code>' + escapeHtml(s.fingerprint) + '</code></td>' +
      '<td><span class="key-stat__value" style="color: var(--color-' + sev + ')">' + escapeHtml(s.status) + '</span></td>' +
      '<td>' + escapeHtml(s.first_seen) + '</td>' +
    '</tr>';
  }).join('');
  const sigHtml = sigRows ? (
    '<section class="report-meta"><h4>Signaturer</h4>' +
      '<table class="report-table"><thead><tr><th>Skill</th><th>Source</th><th>Fingerprint</th><th>Status</th><th>First seen</th></tr></thead><tbody>' + sigRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const fs = (data.findings || []).map(function (f) {
    return Object.assign({}, f, {
      file: f.skill || f.file || '',
      category: f.category || ''
    });
  });
  const findingsHtml = renderFindingsBlock(fs, 'Registry-funn');
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = statsHtml + sigHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'REGISTRY',
    title: data.title || 'Skill-signature registry',
    lede: data.lede || 'Lokal fingerprint-database — kjente goder og kjente onde signaturer.',
    verdict: data.verdict || inferVerdict(data, 'findings'),
    keyStats: data.keyStats || inferKeyStats(data, 'findings')
  }, body);
}
RENDERERS.renderRegistry = renderRegistry;

function renderClean(data, slot) {
  const buckets = data.buckets || { auto: [], 'semi-auto': [], manual: [], suppressed: [] };
  const cardFor = function (bucket, label, sev) {
    const items = buckets[bucket] || [];
    const cards = items.length ? items.map(function (it) {
      return '<div class="kanban-card" data-severity="' + escapeAttr(sev) + '">' +
        '<div class="kanban-card__name">' + escapeHtml(it.id || '—') + ' — ' + escapeHtml(it.action || '') + '</div>' +
        (it.description ? '<div class="kanban-card__meta">' + escapeHtml(it.description) + '</div>' : '') +
      '</div>';
    }).join('') : '<div class="kanban-col__empty">Ingen</div>';
    return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
      '<div class="kanban-col__head">' +
        '<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
        '<span class="kanban-col__count">' + items.length + '</span>' +
      '</div>' + cards + '</div>';
  };
  const kanbanHtml = '<div class="kanban-board" style="grid-template-columns: repeat(4, 1fr);">' +
    cardFor('auto', 'Auto', 'low') +
    cardFor('semi-auto', 'Semi-auto', 'medium') +
    cardFor('manual', 'Manual', 'high') +
    cardFor('suppressed', 'Undertrykt', 'info') +
  '</div>';
  // Advisory recommendation-cards per bucket — DS Tier 3 data-severity (v7.6.0 fase 5f).
  // Every bucket with items > 0 gets one recommendation-card with a severity-tinted border + label.
  const bucketAdvisoryDefs = [
    { key: 'auto', label: 'Auto-fixable', sev: 'positive', desc: 'Plugin kan fikse disse uten ekstra bekreftelse — deterministiske, lavrisiko-handlinger.' },
    { key: 'semi-auto', label: 'Semi-auto — requires confirmation', sev: 'medium', desc: 'Proposed changes are shown as a diff. The user confirms per finding before the change is applied.' },
    { key: 'manual', label: 'Manual remediation', sev: 'high', desc: 'Requires human judgement — context, scope, or side-effects are not deterministically decidable.' },
    { key: 'suppressed', label: 'Undertrykt', sev: 'low', desc: 'Allowlist-treff via .llm-security-ignore — ingen handling.' }
  ];
  const advisoryHtml = bucketAdvisoryDefs.map(function (b) {
    const items = buckets[b.key] || [];
    if (!items.length) return '';
    return (
      '<section class="recommendation-card" data-severity="' + escapeAttr(b.sev) + '">' +
        '<span class="recommendation-card__label">' + escapeHtml(b.label) + ' · ' + items.length + '</span>' +
        '<p class="recommendation-card__body">' + escapeHtml(b.desc) + '</p>' +
      '</section>'
    );
  }).join('');
  const findingsHtml = renderFindingsBlock(data.findings || [], 'Tilknyttede funn');
  const recHtml = renderRecommendationsList(data.recommendations || [], 'Anbefalinger', 'medium');
  const isDry = ((data.mode || '').toLowerCase() === 'dry-run');
  const intro = data.mode ? (
    '<section class="recommendation-card" data-severity="' + (isDry ? 'low' : 'medium') + '">' +
      '<span class="recommendation-card__label">Modus · ' + escapeHtml(data.mode) + '</span>' +
      '<p class="recommendation-card__body">' + (isDry ? 'Dry-run: no files are modified. Preview the actions before <code>--apply</code>.' : 'Fixes are applied with an automatic backup in <code>.llm-security-backup/</code>.') + '</p>' +
    '</section>'
  ) : '';
  const body = intro + advisoryHtml + kanbanHtml + findingsHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'CLEAN',
    title: data.title || 'Remediation-kanban',
    lede: data.lede || 'Findings split across Auto / Semi-auto / Manual / Suppressed.',
    verdict: data.verdict || inferVerdict(data, 'kanban-buckets'),
    keyStats: data.keyStats || inferKeyStats(data, 'kanban-buckets')
  }, body);
}
RENDERERS.renderClean = renderClean;

function renderThreatModel(data, slot) {
  // Matrix-rendering — 5×5
  const cells = data.matrix_cells || [];
  const byPC = {};
  cells.forEach(function (c) {
    const k = c.prob + '_' + c.cons;
    if (!byPC[k]) byPC[k] = [];
    byPC[k].push(c);
  });
  const probSize = 5;
  const consMax = 5;
  let matrixHtml = '<div class="matrix"><div class="matrix__y-label">Konsekvens</div><div class="matrix__main">';
  matrixHtml += '<div class="matrix__grid" style="grid-template-rows: repeat(' + consMax + ', 1fr) 32px;">';
  for (let cons = consMax; cons >= 1; cons--) {
    matrixHtml += '<div class="matrix__y-tick">' + cons + '</div>';
    for (let prob = 1; prob <= probSize; prob++) {
      const score = prob * cons;
      const items = byPC[prob + '_' + cons] || [];
      // v7.6.1 fix: bubbles are now <button> so they are clickable and focusable.
      // data-threat-id lar event-handler senere mappe til detalj-modal.
      const bubblesHtml = items.length
        ? '<div class="matrix__cell-bubbles">' +
            items.slice(0, 3).map(function (it, i) {
              return '<button type="button" class="matrix__bubble" data-threat-id="' + escapeAttr(it.id || it.label || '') + '" title="' + escapeAttr(it.label || '') + '" aria-label="Trussel: ' + escapeAttr(it.label || it.id || '') + '">' + (i + 1) + '</button>';
            }).join('') +
            (items.length > 3 ? '<button type="button" class="matrix__bubble matrix__bubble--count" aria-label="' + (items.length - 3) + ' flere trusler">+' + (items.length - 3) + '</button>' : '') +
          '</div>'
        : '';
      matrixHtml += '<div class="matrix__cell" data-score="' + score + '">' +
                      '<span class="matrix__cell-score">' + score + '</span>' + bubblesHtml +
                    '</div>';
    }
  }
  matrixHtml += '<div class="matrix__corner"></div>';
  for (let prob = 1; prob <= probSize; prob++) {
    matrixHtml += '<div class="matrix__x-tick">' + prob + '</div>';
  }
  matrixHtml += '</div><div class="matrix__x-label">Sannsynlighet</div></div></div>';
  // Threats table
  const threatsRows = (data.threats || []).map(function (t) {
    return '<tr>' +
      '<td>' + escapeHtml(t.id) + '</td>' +
      '<td>' + escapeHtml(t.description) + '</td>' +
      '<td><span class="findings__item-severity-dot" data-severity="' + escapeAttr(t.severity || 'info') + '" style="display: inline-block; vertical-align: middle;"></span> ' + escapeHtml(t.severity) + '</td>' +
      '<td>' + escapeHtml(t.mitigation) + '</td>' +
    '</tr>';
  }).join('');
  const threatsHtml = threatsRows ? (
    '<section class="report-meta"><h4>Trusler</h4>' +
      '<table class="report-table"><thead><tr><th>ID</th><th>Beskrivelse</th><th>Severity</th><th>Tiltak</th></tr></thead><tbody>' + threatsRows + '</tbody></table>' +
    '</section>'
  ) : '';
  // STRIDE / MAESTRO coverage as side-by-side bar lists
  const coverageBlock = function (rows, label) {
    if (!rows || !rows.length) return '';
    const max = Math.max.apply(null, rows.map(function (r) { return Number(r.count) || 0; })) || 1;
    const items = rows.map(function (r) {
      const pct = ((Number(r.count) || 0) / max) * 100;
      const labelKey = r.category || r.layer || '';
      return '<div class="sm-card">' +
               '<div class="sm-card__header">' +
                 '<span class="sm-card__name">' + escapeHtml(labelKey) + '</span>' +
                 '<span class="sm-card__grade" data-grade="' + (r.count === 0 ? '?' : r.count <= 1 ? 'A' : r.count <= 3 ? 'B' : 'C') + '">' + r.count + '</span>' +
               '</div>' +
               '<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + pct.toFixed(0) + '%"></div></div>' +
               (r.notes ? '<span class="sm-card__status">' + escapeHtml(r.notes) + '</span>' : '') +
             '</div>';
    }).join('');
    return '<section class="report-meta"><h4>' + escapeHtml(label) + '</h4><div class="small-multiples">' + items + '</div></section>';
  };
  const strideHtml = coverageBlock(data.stride, 'STRIDE-dekning');
  const maestroHtml = coverageBlock(data.maestro, 'MAESTRO-dekning');
  // Roadmap
  const roadRows = (data.roadmap || []).map(function (r) {
    return '<tr><td>' + escapeHtml(r.priority) + '</td><td>' + escapeHtml(r.threat_id) + '</td><td>' + escapeHtml(r.mitigation) + '</td><td>' + escapeHtml(r.owner) + '</td><td>' + escapeHtml(r.eta) + '</td></tr>';
  }).join('');
  const roadHtml = roadRows ? (
    '<section class="report-meta"><h4>Mitigation roadmap</h4>' +
      '<table class="report-table"><thead><tr><th>Prioritet</th><th>Trussel</th><th>Tiltak</th><th>Eier</th><th>ETA</th></tr></thead><tbody>' + roadRows + '</tbody></table>' +
    '</section>'
  ) : '';
  const recHtml = renderRecommendationsList(data.recommendations || []);
  const body = matrixHtml + threatsHtml + strideHtml + maestroHtml + roadHtml + recHtml;
  slot.innerHTML = renderPageShell({
    eyebrow: 'THREAT-MODEL',
    title: data.title || 'Threat model · STRIDE + MAESTRO',
    lede: data.lede || 'Trusselmodellering med risikomatrise og mitigation-roadmap.',
    verdict: data.verdict || inferVerdict(data, 'matrix-risk'),
    keyStats: data.keyStats || inferKeyStats(data, 'matrix-risk')
  }, body);
}
RENDERERS.renderThreatModel = renderThreatModel;

// ============================================================
// EXPORTS — single block; functions remain top-level declarations
// for parity with the inline playground copy.
// ============================================================
export {
  // escape
  escapeHtml, escapeAttr,
  // verdict + key-stats inference
  normalizeVerdict, inferVerdict, KEY_STATS_CONFIG, inferKeyStats,
  // page-shell helpers
  renderVerdictPill, renderKeyStatsGrid, renderPageShell,
  // parser helpers
  parseTableRow, parseTable, parseAllTables, parseSections,
  extractField, intOrZero, emptyInput, normalizeSeverity,
  normalizeVerdictText, gradeFromText,
  parseRiskDashboard, parseFindingsTables, parseRecommendations,
  safeOk, parseNarrativeAudit,
  // 18 parsers
  parseScan, parseDeepScan, parsePluginAudit, parseMcpAudit,
  parseMcpInspect, parseIdeScan, parseSupplyCheck, parsePosture,
  parseAudit, parseDashboard, parsePreDeploy, parseDiff, parseWatch,
  parseRegistry, parseClean, parseHarden, parseThreatModel, parseRedTeam,
  // routing maps
  PARSERS, RENDERERS,
  // renderer helpers
  renderEmptyState, renderFindingsBlock, renderRecommendationsList,
  mapSeverityToCardLevel, renderRiskMeter, renderSmallMultiples,
  renderRadarSvg, renderToxicFlow, renderMatLadder, renderSuppressedGroup,
  renderCodepointReveal, renderTopRisks,
  // 18 renderers
  renderScan, renderDeepScan, renderPluginAudit, renderMcpAudit,
  renderIdeScan, renderPosture, renderAudit, renderDashboard,
  renderHarden, renderRedTeam, renderMcpInspect, renderSupplyCheck,
  renderPreDeploy, renderDiff, renderWatch, renderRegistry,
  renderClean, renderThreatModel
};
