feat(ms-ai-architect): add renderPageShell + verdict + keyStats helpers (v2 foundation)

This commit is contained in:
Kjell Tore Guttormsen 2026-05-04 03:10:39 +02:00
commit 1fe40fe886

View file

@ -148,6 +148,34 @@
.catalog-card__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); font-family: var(--font-family-mono); }
.catalog-card__actions { display: flex; gap: var(--space-2); margin-top: auto; padding-top: var(--space-2); }
.catalog-tool-notice { padding: var(--space-2) var(--space-3); background: var(--color-bg-soft); border-left: 3px solid var(--color-primary-500); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--color-text-secondary); }
/* Foundation page-shell helpers (v1.10.0 Sesjon 1).
Felles header + verdict-pille + key-stats-grid for alle 17 renderers.
Kandidater for hoisting til shared/playground-design-system/ i en
senere iterasjon (Sesjon 6 visual QA). */
.page__header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-6); margin: 0 0 var(--space-6) 0; }
.page__title { flex: 1 1 auto; min-width: 0; }
.page__eyebrow { display: block; font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-scope-architect); margin-bottom: var(--space-2); }
.page__title h1 { margin: 0 0 var(--space-3) 0; font-size: var(--font-size-3xl); line-height: var(--line-height-tight); color: var(--color-text-primary); }
.page__lede { margin: 0; max-width: var(--measure); font-size: var(--font-size-lg); color: var(--color-text-secondary); line-height: var(--line-height-snug); }
.verdict-pill { display: inline-flex; align-items: center; padding: var(--space-2) var(--space-4); border-radius: var(--radius-pill); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap; flex-shrink: 0; }
.verdict-pill[data-verdict="go"],
.verdict-pill[data-verdict="approved"],
.verdict-pill[data-verdict="allow"] { background: var(--color-state-success); color: #fff; }
.verdict-pill[data-verdict="go-with-conditions"],
.verdict-pill[data-verdict="warning"] { background: var(--color-severity-medium); color: var(--color-severity-medium-on); }
.verdict-pill[data-verdict="block"],
.verdict-pill[data-verdict="failed"] { background: var(--color-severity-critical); color: var(--color-severity-critical-on); }
.verdict-pill[data-verdict="n-a"] { background: var(--color-bg-soft); color: var(--color-text-secondary); border: 1px solid var(--color-border-subtle); }
.key-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-4); margin: 0 0 var(--space-6) 0; padding: var(--space-4); background: var(--color-bg-soft); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); }
.key-stat { display: flex; flex-direction: column; gap: 2px; }
.key-stat__label { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; }
.key-stat__value { font-size: var(--font-size-2xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); font-variant-numeric: tabular-nums; line-height: var(--line-height-tight); }
.key-stat__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.key-stat[data-modifier="critical"] .key-stat__value { color: var(--color-severity-critical); }
.key-stat[data-modifier="high"] .key-stat__value { color: var(--color-severity-high); }
.key-stat[data-modifier="medium"] .key-stat__value { color: var(--color-severity-medium); }
.key-stat[data-modifier="low"] .key-stat__value { color: var(--color-severity-low); }
</style>
</head>
<body>
@ -3209,6 +3237,288 @@
slot.innerHTML = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
}
// ============================================================
// FOUNDATION HELPERS (v1.10.0 Sesjon 1)
// ============================================================
//
// Felles grunnskjelett for alle 17 renderers. Sesjon 3-5 wrapper hver
// renderer med renderPageShell({...}, bodyHtml) — body forblir mest
// uendret, header/verdict/keyStats kommer fra denne foundation-laget.
//
// V2-data-shape (parser-output utvides — beholder v1-felter):
// data.verdict?: 'go'|'go-with-conditions'|'block'|'approved'|'failed'|
// 'allow'|'warning'|'n-a'
// data.keyStats?: Array<{label, value, hint?, modifier?}>
//
// MIGRATIONS v1->v2 i bootstrap (se migrateDataVersion under) utleder
// verdict + keyStats fra v1-felter idempotent for eksisterende state.
const VERDICT_NORMAL = {
'go': 'go', 'godkjent': 'approved', 'approved': 'approved',
'go-with-conditions': 'go-with-conditions', 'conditions': 'go-with-conditions', 'betinget': 'go-with-conditions',
'block': 'block', 'blokkert': 'block', 'forbudt': 'block', 'forbidden': 'block',
'failed': 'failed', 'feilet': 'failed', 'underkjent': 'failed',
'allow': 'allow', 'tillatt': 'allow',
'warning': 'warning', 'advarsel': 'warning',
'n-a': 'n-a', 'na': 'n-a'
};
function normalizeVerdict(raw) {
if (raw == null) return 'n-a';
const k = String(raw).toLowerCase().trim();
return VERDICT_NORMAL[k] || 'n-a';
}
function riskLevelModifier(level) {
const k = String(level || '').toLowerCase();
if (k === 'forbudt' || k === 'forbidden') return 'critical';
if (k === 'høy' || k === 'high') return 'high';
if (k === 'begrenset' || k === 'limited') return 'medium';
if (k === 'minimal' || k === 'low') return 'low';
return undefined;
}
function formatNok(n) {
if (n == null) return '—';
const num = Number(n);
if (!isFinite(num)) return String(n);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return Math.round(num / 1000) + 'k';
return String(num);
}
// Per-archetype default keyStats utledere. Hver tar v2-data, returnerer
// Array<{label, value, hint?, modifier?}>. Tom array hvis archetype ikke
// har et naturlig keyStats-aggregat (transparency, plain markdown).
const KEY_STATS_CONFIG = {
'aiact': function (d) {
return [
{ label: 'RISIKONIVÅ', value: d.risk_level || '—', modifier: riskLevelModifier(d.risk_level) },
{ label: 'ROLLE', value: d.role || '—' },
{ label: 'FORPLIKTELSER', value: (d.obligations || []).length, hint: 'antall' }
];
},
'requirements-list': function (d) {
const items = d.items || [];
const required = items.filter(function (i) { return /påkrev|required/i.test(i.status || ''); }).length;
return [
{ label: 'KRAV', value: items.length },
{ label: 'PÅKREVD', value: required, modifier: required ? 'high' : 'low' }
];
},
'text-document': function () { return []; },
'fria': function (d) {
const rights = d.rights || [];
return [
{ label: 'BERØRTE GRUPPER', value: rights.length },
{ label: 'MITIGERENDE', value: rights.filter(function (r) { return r.mitigation; }).length, hint: 'tiltak' }
];
},
'conformity-checklist': function (d) {
const cl = d.checklist || [];
const passed = cl.filter(function (c) { return /pass|bestått|ok/i.test(c.status || ''); }).length;
return [
{ label: 'KRITERIER', value: cl.length },
{ label: 'BESTÅTT', value: passed, modifier: passed === cl.length ? 'low' : 'medium' },
{ label: 'FRISTER', value: (d.deadlines || []).length, hint: 'kommende' }
];
},
'matrix-risk': function (d) {
const threats = d.threats || [];
const high = threats.filter(function (t) {
const s = String(t.severity || '').toLowerCase();
return s === 'høy' || s === 'high' || s === 'kritisk' || s === 'critical';
}).length;
return [
{ label: 'TRUSLER', value: threats.length },
{ label: 'HØY/KRITISK', value: high, modifier: high ? 'high' : 'low' },
{ label: 'CELLER', value: (d.matrix_cells || []).length, hint: 'i matrise' }
];
},
'matrix-risk-6x5': function (d) {
const findings = d.findings || [];
const dims = d.dimensions || [];
const sum = dims.reduce(function (a, b) { return a + (Number(b.score) || 0); }, 0);
const avg = dims.length ? (sum / dims.length).toFixed(1) : '—';
return [
{ label: 'DIMENSJONER', value: dims.length },
{ label: 'SNITT', value: avg, hint: 'av 5' },
{ label: 'FUNN', value: findings.length, modifier: findings.length > 5 ? 'high' : 'medium' }
];
},
'findings': function (d) {
const f = d.findings || [];
const crit = f.filter(function (x) { return /crit|kritisk/i.test(x.severity || ''); }).length;
return [
{ label: 'FUNN', value: f.length },
{ label: 'KRITISKE', value: crit, modifier: crit ? 'critical' : 'low' }
];
},
'cost-distribution': function (d) {
return [
{ label: 'P50', value: formatNok(d.p50), hint: 'median' },
{ label: 'P90', value: formatNok(d.p90), hint: 'pessimistisk', modifier: 'high' },
{ label: 'KOMPONENTER', value: (d.monthly_breakdown || []).length }
];
},
'capability': function (d) {
const lic = d.licenses || [];
const totalCaps = lic.reduce(function (a, l) { return a + ((l.capabilities || []).length); }, 0);
return [
{ label: 'LISENSER', value: lic.length },
{ label: 'KAPABILITETER', value: totalCaps }
];
},
'phased-plan': function (d) {
const phases = d.phases || [];
const totalWeeks = phases.reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
const risks = d.risks || [];
return [
{ label: 'FASER', value: phases.length },
{ label: 'VARIGHET', value: totalWeeks || '—', hint: 'uker totalt' },
{ label: 'RISIKOER', value: risks.length, modifier: risks.length > 3 ? 'high' : 'medium' }
];
},
'markdown': function (d) {
const sec = d.sections || [];
return sec.length ? [{ label: 'SEKSJONER', value: sec.length }] : [];
},
'verdict': function (d) {
const km = d.key_metrics || [];
return km.slice(0, 4).map(function (m) {
return {
label: String(m.label || m.name || '').toUpperCase(),
value: m.value != null ? m.value : '—',
hint: m.unit || undefined
};
});
},
'comparison': function (d) {
return [
{ label: 'KANDIDATER', value: (d.subjects || []).length },
{ label: 'DIMENSJONER', value: (d.rows || []).length }
];
}
};
function inferVerdict(data, archetype) {
if (!data) return 'n-a';
// Eksplisitt verdict tar prioritet uansett kilde.
if (data.verdict) return normalizeVerdict(data.verdict);
switch (archetype) {
case 'aiact': {
const lvl = String(data.risk_level || '').toLowerCase();
if (lvl === 'forbudt' || lvl === 'forbidden') return 'block';
if (lvl === 'høy' || lvl === 'high') return 'warning';
if (lvl === 'begrenset' || lvl === 'limited') return 'go-with-conditions';
if (lvl === 'minimal' || lvl === 'low') return 'go';
return 'n-a';
}
case 'matrix-risk':
case 'matrix-risk-6x5': {
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';
}
case 'conformity-checklist': {
const cl = data.checklist || [];
if (!cl.length) return 'n-a';
const anyFailed = cl.some(function (c) { return /fail|underkjent/i.test(c.status || ''); });
if (anyFailed) return 'failed';
const allPassed = cl.every(function (c) { return /pass|bestått|ok/i.test(c.status || ''); });
if (allPassed) return 'approved';
return 'go-with-conditions';
}
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 'cost-distribution': {
if (data.p90 != null && data.p50 != null) {
const ratio = Number(data.p90) / Math.max(Number(data.p50), 1);
return ratio > 2 ? 'warning' : 'go';
}
return 'n-a';
}
default:
return 'n-a';
}
}
function inferKeyStats(data, archetype) {
if (!data) return [];
// Eksplisitt keyStats tar prioritet
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 [];
}
}
function renderVerdictPill(verdict) {
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'
};
return '<span class="verdict-pill" data-verdict="' + escapeAttr(v) + '">' +
escapeHtml(labels[v] || v.toUpperCase()) +
'</span>';
}
function renderKeyStatsGrid(stats) {
if (!stats || !stats.length) return '';
const items = stats.map(function (s) {
const mod = s.modifier ? ' data-modifier="' + escapeAttr(s.modifier) + '"' : '';
const hint = s.hint ? '<span class="key-stat__hint">' + escapeHtml(s.hint) + '</span>' : '';
return '<div class="key-stat"' + mod + '>' +
'<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>';
}
function renderPageShell(opts, bodyHtml) {
opts = opts || {};
const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
const title = '<h1>' + escapeHtml(opts.title || '') + '</h1>';
const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict) : '';
const stats = renderKeyStatsGrid(opts.keyStats);
return (
'<header class="page__header">' +
'<div class="page__title">' + eyebrow + title + lede + '</div>' +
verdict +
'</header>' +
stats +
(bodyHtml || '')
);
}
// Eksponer for tester og fremtidig renderer-iterasjon (Sesjon 3-5)
window.__renderPageShell = renderPageShell;
window.__renderVerdictPill = renderVerdictPill;
window.__renderKeyStatsGrid = renderKeyStatsGrid;
window.__inferVerdict = inferVerdict;
window.__inferKeyStats = inferKeyStats;
window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG;
// ---- RENDERERS routing-objekt (17 commands) ----
const RENDERERS = {