feat(ms-ai-architect): add renderPageShell + verdict + keyStats helpers (v2 foundation)
This commit is contained in:
parent
3c933ae3fa
commit
1fe40fe886
1 changed files with 310 additions and 0 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue