Aksel/Digdir-aligned design system for plugin Playgrounds — visual self-service UIs that complement terminal slash-commands. Targets ms-ai-architect, okr, llm-security, ultraplan-local, config-audit. Built for Norwegian public sector decision-makers plus developer power-users — one visual family, two info densities. Generated by claude.ai/design (Anthropic) in a dialog-based design session driven by a comprehensive brief covering all five target plugins, Aksel/Digdir conventions, and domain-specific visual standards (NS 5814 ROS matrices, EU AI Act 4-tier pyramide, Doerr OKR scoring, NIST CSF, OWASP threat modeling). Per Anthropic Consumer Terms §4, ownership of outputs is assigned to the user; licensed MIT. shared/playground-design-system/ (5874 lines CSS + JSON): - tokens.css: Inter font, Digdir blue #0062BA, deuteranopia-safe severity ramp, distinct severity-red (#A40E26) vs failure-red (#7D1A1A), plugin scope colors, light + dark themes - base.css: reset, typography (17px body, 65ch measure), focus rings, buttons, badges, forms, Aksel 3-tier inline messages, prefers-reduced-motion support - components.css: Tier 1 — radar/spider, 5x5 matrix-heatmap (bottom-left origin, ROS/DPIA), findings-browser, critique-card, wizard/stepper, live-meter with antipattern lints - components-tier2.css: Tier 2 — decision-tree, traffic-lights with rationale, diff-review, treemap, distribution P10/P50/P90, command-pipeline output, AI Act 4-color pyramide, pipeline-cockpit, verdict-pill + 5-band risk-meter, codepoint-reveal (Unicode steg), small-multiples grid (16-cat posture), OWASP badges (LLM/ASI/AST/MCP) - print.css: A4 stylesheet with BW severity hatching, kommune-logo slot, signature lines for offentlige dokumenter - schemas/: finding.schema.json, okr-set.schema.json, ros-threat.schema.json - README.md: usage guide, design principles, component reference, provenance shared/playground-examples/: - index.html: system showcase with all components live - ros-lier-kommune.html: Lier kommune Copilot ROS-rapport (Scenario A) - okr-baerum.html: Baerum kommune T2-2026 OKR live writer (Scenario B) - security-vegvesen.html: SVV ToxicSkills findings review, 85 funn BLOCK (Scenario C) - templates.html: A4 print template demos - ros-app.js + ros-data.js: Scenario A interactivity WCAG 2.1 AA throughout (UU-loven krav for offentlig sektor): focus rings, ARIA attributes, keyboard navigation, severity numerical redundancy for deuteranopia and BW print, semantic HTML. Known limitation: Inter loaded via Google Fonts CDN violates self-contained no-CDN constraint. System-stack fallback works offline. Self-host woff2 files in Phase 2.
393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
/* ros-app.js — Scenario A interactivity */
|
||
(function () {
|
||
const data = window.ROS_DATA;
|
||
|
||
/* -------------------------------------------------- THEME TOGGLE */
|
||
const themeToggle = document.getElementById('themeToggle');
|
||
const themeLabel = document.getElementById('themeLabel');
|
||
const stored = localStorage.getItem('ros-theme');
|
||
if (stored) document.documentElement.setAttribute('data-theme', stored);
|
||
function syncThemeLabel() {
|
||
const t = document.documentElement.getAttribute('data-theme') || 'light';
|
||
themeLabel.textContent = t === 'dark' ? 'Lyst' : 'Mørkt';
|
||
}
|
||
syncThemeLabel();
|
||
themeToggle.addEventListener('click', () => {
|
||
const cur = document.documentElement.getAttribute('data-theme') || 'light';
|
||
const next = cur === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', next);
|
||
localStorage.setItem('ros-theme', next);
|
||
syncThemeLabel();
|
||
drawRadar(); // redraw since some colors are computed
|
||
});
|
||
|
||
/* -------------------------------------------------- SCREEN ROUTING */
|
||
const tabs = document.querySelectorAll('.screen-tab');
|
||
const screens = document.querySelectorAll('.screen');
|
||
function showScreen(name) {
|
||
tabs.forEach(t => t.setAttribute('aria-current', t.dataset.screen === name ? 'true' : 'false'));
|
||
screens.forEach(s => s.dataset.active = s.dataset.screen === name ? 'true' : 'false');
|
||
history.replaceState(null, '', '#' + name);
|
||
}
|
||
tabs.forEach(t => t.addEventListener('click', () => showScreen(t.dataset.screen)));
|
||
document.querySelectorAll('[data-goto]').forEach(b => b.addEventListener('click', () => showScreen(b.dataset.goto)));
|
||
const initial = (location.hash || '#matrix').slice(1);
|
||
if (['intake','matrix','findings','summary'].includes(initial)) showScreen(initial);
|
||
else showScreen('matrix');
|
||
|
||
/* -------------------------------------------------- MATRIX */
|
||
// 5x5 grid + axis ticks. Bottom-left origin: row 5 = konsekvens 5 (highest at top)
|
||
const matrix = document.getElementById('rosMatrix');
|
||
let showResidual = false;
|
||
|
||
function buildMatrix() {
|
||
matrix.innerHTML = '';
|
||
// For each row from konsekvens=5 down to 1
|
||
for (let k = 5; k >= 1; k--) {
|
||
// Y-tick
|
||
const tick = document.createElement('div');
|
||
tick.className = 'matrix__y-tick';
|
||
tick.textContent = k;
|
||
matrix.appendChild(tick);
|
||
// 5 cells
|
||
for (let s = 1; s <= 5; s++) {
|
||
const cell = document.createElement('button');
|
||
cell.type = 'button';
|
||
const score = s * k;
|
||
cell.className = 'matrix__cell';
|
||
cell.dataset.score = score;
|
||
cell.dataset.s = s;
|
||
cell.dataset.k = k;
|
||
cell.setAttribute('aria-label', `Sannsynlighet ${s}, konsekvens ${k}, score ${score}`);
|
||
|
||
const scoreLabel = document.createElement('span');
|
||
scoreLabel.className = 'matrix__cell-score';
|
||
scoreLabel.textContent = score;
|
||
cell.appendChild(scoreLabel);
|
||
|
||
const bubbles = document.createElement('span');
|
||
bubbles.className = 'matrix__cell-bubbles';
|
||
|
||
// Find threats in this cell
|
||
const threats = data.threats.filter(t => {
|
||
const sa = showResidual ? t.restrisiko.sannsynlighet : t.sannsynlighet;
|
||
const ko = showResidual ? t.restrisiko.konsekvens : t.konsekvens;
|
||
return sa === s && ko === k;
|
||
});
|
||
threats.slice(0, 3).forEach(t => {
|
||
const b = document.createElement('span');
|
||
b.className = 'matrix__bubble';
|
||
b.textContent = t.id;
|
||
b.title = t.tittel;
|
||
bubbles.appendChild(b);
|
||
});
|
||
// Aggregate count from cellCounts (only when not showing residual)
|
||
const extra = !showResidual ? (data.cellCounts[`${s},${k}`] || 0) : 0;
|
||
const overflow = (threats.length > 3) ? (threats.length - 3) : 0;
|
||
const totalExtra = extra + overflow;
|
||
if (totalExtra > 0) {
|
||
const c = document.createElement('span');
|
||
c.className = 'matrix__bubble matrix__bubble--count';
|
||
c.textContent = '+' + totalExtra;
|
||
bubbles.appendChild(c);
|
||
}
|
||
cell.appendChild(bubbles);
|
||
|
||
cell.addEventListener('click', () => {
|
||
// Pick first named threat in this cell, else show count info
|
||
if (threats.length) openThreatPanel(threats[0].id);
|
||
});
|
||
matrix.appendChild(cell);
|
||
}
|
||
}
|
||
// Bottom row: corner + 5 x-ticks
|
||
const corner = document.createElement('div');
|
||
corner.className = 'matrix__corner';
|
||
matrix.appendChild(corner);
|
||
for (let s = 1; s <= 5; s++) {
|
||
const xt = document.createElement('div');
|
||
xt.className = 'matrix__x-tick';
|
||
xt.textContent = s;
|
||
matrix.appendChild(xt);
|
||
}
|
||
}
|
||
buildMatrix();
|
||
|
||
document.getElementById('toggleResidual').addEventListener('click', (e) => {
|
||
showResidual = !showResidual;
|
||
e.target.textContent = showResidual ? 'Vis nåværende risiko' : 'Vis restrisiko etter tiltak';
|
||
buildMatrix();
|
||
});
|
||
|
||
/* -------------------------------------------------- RADAR */
|
||
function drawRadar() {
|
||
const svg = document.querySelector('.radar__svg #radarGrid');
|
||
if (!svg) return;
|
||
svg.innerHTML = '';
|
||
const axes = data.radarAxes;
|
||
const N = axes.length;
|
||
const R = 100;
|
||
// Grid rings
|
||
for (let r = 1; r <= 5; r++) {
|
||
const radius = (R / 5) * r;
|
||
const points = [];
|
||
for (let i = 0; i < N; i++) {
|
||
const a = (-Math.PI / 2) + (i / N) * Math.PI * 2;
|
||
points.push((Math.cos(a) * radius).toFixed(2) + ',' + (Math.sin(a) * radius).toFixed(2));
|
||
}
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
poly.setAttribute('points', points.join(' '));
|
||
poly.setAttribute('class', 'radar__grid-line');
|
||
svg.appendChild(poly);
|
||
}
|
||
// Axes
|
||
for (let i = 0; i < N; i++) {
|
||
const a = (-Math.PI / 2) + (i / N) * Math.PI * 2;
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||
line.setAttribute('x1', 0); line.setAttribute('y1', 0);
|
||
line.setAttribute('x2', (Math.cos(a) * R).toFixed(2));
|
||
line.setAttribute('y2', (Math.sin(a) * R).toFixed(2));
|
||
line.setAttribute('class', 'radar__axis');
|
||
svg.appendChild(line);
|
||
// Label
|
||
const lx = Math.cos(a) * (R + 22);
|
||
const ly = Math.sin(a) * (R + 22);
|
||
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||
txt.setAttribute('x', lx.toFixed(2));
|
||
txt.setAttribute('y', (ly + 4).toFixed(2));
|
||
txt.setAttribute('class', 'radar__label');
|
||
txt.textContent = axes[i].label;
|
||
svg.appendChild(txt);
|
||
}
|
||
// Series helper
|
||
function series(values, klass) {
|
||
const points = [];
|
||
for (let i = 0; i < N; i++) {
|
||
const a = (-Math.PI / 2) + (i / N) * Math.PI * 2;
|
||
const r = (values[i] / 5) * R;
|
||
points.push((Math.cos(a) * r).toFixed(2) + ',' + (Math.sin(a) * r).toFixed(2));
|
||
}
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||
poly.setAttribute('points', points.join(' '));
|
||
poly.setAttribute('class', klass);
|
||
svg.appendChild(poly);
|
||
}
|
||
series(axes.map(a => a.target), 'radar__series radar__series--target');
|
||
series(axes.map(a => a.current), 'radar__series');
|
||
|
||
// Scores list
|
||
const dl = document.getElementById('radarScores');
|
||
if (dl) {
|
||
dl.innerHTML = '';
|
||
axes.forEach(a => {
|
||
const row = document.createElement('div');
|
||
row.className = 'radar__score-row';
|
||
row.innerHTML = `<dt>${a.label}</dt><dd>${a.current.toFixed(1)} → ${a.target.toFixed(1)}</dd>`;
|
||
dl.appendChild(row);
|
||
});
|
||
}
|
||
}
|
||
drawRadar();
|
||
|
||
/* -------------------------------------------------- FINDINGS BROWSER */
|
||
const findingsGroups = document.getElementById('findingsGroups');
|
||
const findingDetail = document.getElementById('findingDetail');
|
||
|
||
function severityFromScore(score) {
|
||
if (score >= 20) return 'critical';
|
||
if (score >= 15) return 'high';
|
||
if (score >= 9) return 'medium';
|
||
return 'low';
|
||
}
|
||
function zoneFromScore(score) {
|
||
if (score >= 20) return 'critical';
|
||
if (score >= 15) return 'high';
|
||
if (score >= 9) return 'medium';
|
||
return 'low';
|
||
}
|
||
|
||
function buildFindings() {
|
||
findingsGroups.innerHTML = '';
|
||
const grouped = { critical: [], high: [], medium: [], low: [] };
|
||
data.threats.forEach(t => {
|
||
const sev = severityFromScore(t.sannsynlighet * t.konsekvens);
|
||
grouped[sev].push(t);
|
||
});
|
||
const labels = { critical: 'Kritisk', high: 'Høy', medium: 'Middels', low: 'Lav' };
|
||
Object.keys(grouped).forEach(sev => {
|
||
if (!grouped[sev].length) return;
|
||
const grp = document.createElement('div');
|
||
grp.className = 'findings__group';
|
||
const hdr = document.createElement('div');
|
||
hdr.className = 'findings__group-header';
|
||
hdr.innerHTML = `<span>${labels[sev]}</span><span>${grouped[sev].length}</span>`;
|
||
grp.appendChild(hdr);
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'findings__items';
|
||
grouped[sev].forEach(t => {
|
||
const li = document.createElement('li');
|
||
li.className = 'findings__item';
|
||
li.tabIndex = 0;
|
||
li.dataset.id = t.id;
|
||
li.innerHTML = `
|
||
<span class="findings__item-severity-dot" data-severity="${sev}" aria-hidden="true"></span>
|
||
<span class="findings__item-id">${t.id} · ${t.kategori}</span>
|
||
<span class="findings__item-title">${t.tittel}</span>
|
||
<span class="findings__item-meta">
|
||
<span class="badge badge--severity-${sev}">${t.sannsynlighet}×${t.konsekvens} = ${t.sannsynlighet*t.konsekvens}</span>
|
||
<span class="badge">${t.mitigeringer.length} mitig.</span>
|
||
</span>
|
||
`;
|
||
li.addEventListener('click', () => selectFinding(t.id));
|
||
li.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectFinding(t.id); }
|
||
});
|
||
ul.appendChild(li);
|
||
});
|
||
grp.appendChild(ul);
|
||
findingsGroups.appendChild(grp);
|
||
});
|
||
}
|
||
|
||
function selectFinding(id) {
|
||
document.querySelectorAll('.findings__item').forEach(el => {
|
||
el.setAttribute('aria-selected', el.dataset.id === id ? 'true' : 'false');
|
||
});
|
||
renderFindingDetail(id);
|
||
}
|
||
|
||
function renderFindingDetail(id) {
|
||
const t = data.threats.find(x => x.id === id);
|
||
if (!t) return;
|
||
const cur = t.sannsynlighet * t.konsekvens;
|
||
const res = t.restrisiko.sannsynlighet * t.restrisiko.konsekvens;
|
||
findingDetail.innerHTML = `
|
||
<div class="threat-detail">
|
||
<div>
|
||
<div class="threat-detail__id">${t.id} · ${t.kategori}</div>
|
||
<h2 class="threat-detail__title">${t.tittel}</h2>
|
||
</div>
|
||
|
||
<div class="residual-pair">
|
||
<div class="residual-cell" data-zone="${zoneFromScore(cur)}">
|
||
<div class="residual-cell__label">Før tiltak</div>
|
||
<div class="residual-cell__value">${cur}</div>
|
||
<div class="text-xs">${t.sannsynlighet} × ${t.konsekvens}</div>
|
||
</div>
|
||
<div class="residual-arrow" aria-hidden="true">→</div>
|
||
<div class="residual-cell" data-zone="${zoneFromScore(res)}">
|
||
<div class="residual-cell__label">Etter tiltak</div>
|
||
<div class="residual-cell__value">${res}</div>
|
||
<div class="text-xs">${t.restrisiko.sannsynlighet} × ${t.restrisiko.konsekvens}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="threat-detail__section">
|
||
<h4>Beskrivelse</h4>
|
||
<p>${t.kilde}</p>
|
||
</div>
|
||
<div class="threat-detail__section">
|
||
<h4>Begrunnelse — sannsynlighet ${t.sannsynlighet}/5</h4>
|
||
<p>${t.sannsynlighetBegrunnelse}</p>
|
||
</div>
|
||
<div class="threat-detail__section">
|
||
<h4>Begrunnelse — konsekvens ${t.konsekvens}/5</h4>
|
||
<p>${t.konsekvensBegrunnelse}</p>
|
||
</div>
|
||
<div class="threat-detail__section">
|
||
<h4>Mitigeringer (${t.mitigeringer.length})</h4>
|
||
<ul class="mitigation-list">
|
||
${t.mitigeringer.map(m => `
|
||
<li class="mitigation">
|
||
<span class="mitigation__id">${m.id}</span>
|
||
<span>${m.tittel}</span>
|
||
<span class="mitigation__status" data-status="${m.status}">${
|
||
m.status === 'implemented' ? 'Implementert' :
|
||
m.status === 'planned' ? 'Planlagt' : 'Foreslått'
|
||
}</span>
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||
<button type="button" class="btn btn--primary btn--sm">Godkjenn vurdering</button>
|
||
<button type="button" class="btn btn--secondary btn--sm">Be om revurdering</button>
|
||
<button type="button" class="btn btn--ghost btn--sm">Eksporter</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
buildFindings();
|
||
selectFinding('T-001');
|
||
|
||
/* -------------------------------------------------- SIDEPANEL (matrix click) */
|
||
const sidepanel = document.getElementById('sidepanel');
|
||
const scrim = document.getElementById('scrim');
|
||
function openThreatPanel(id) {
|
||
const t = data.threats.find(x => x.id === id);
|
||
if (!t) return;
|
||
document.getElementById('sidepanelId').textContent = `${t.id} · ${t.kategori}`;
|
||
document.getElementById('sidepanelTitle').textContent = t.tittel;
|
||
const cur = t.sannsynlighet * t.konsekvens;
|
||
const res = t.restrisiko.sannsynlighet * t.restrisiko.konsekvens;
|
||
document.getElementById('sidepanelBody').innerHTML = `
|
||
<div class="threat-detail">
|
||
<div class="residual-pair">
|
||
<div class="residual-cell" data-zone="${zoneFromScore(cur)}">
|
||
<div class="residual-cell__label">Før tiltak</div>
|
||
<div class="residual-cell__value">${cur}</div>
|
||
</div>
|
||
<div class="residual-arrow" aria-hidden="true">→</div>
|
||
<div class="residual-cell" data-zone="${zoneFromScore(res)}">
|
||
<div class="residual-cell__label">Etter tiltak</div>
|
||
<div class="residual-cell__value">${res}</div>
|
||
</div>
|
||
</div>
|
||
<div class="threat-detail__section"><h4>Beskrivelse</h4><p>${t.kilde}</p></div>
|
||
<div class="threat-detail__section"><h4>Mitigeringer</h4>
|
||
<ul class="mitigation-list">${t.mitigeringer.map(m => `
|
||
<li class="mitigation"><span class="mitigation__id">${m.id}</span><span>${m.tittel}</span>
|
||
<span class="mitigation__status" data-status="${m.status}">${m.status === 'implemented' ? 'Implementert' : m.status === 'planned' ? 'Planlagt' : 'Foreslått'}</span></li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
<button type="button" class="btn btn--primary" onclick="document.querySelector('[data-screen=\\'findings\\']').click(); document.getElementById('sidepanelClose').click(); setTimeout(() => { document.querySelectorAll('.findings__item').forEach(el => { if (el.dataset.id === '${t.id}') el.click(); }); }, 50);">Åpne i funnliste</button>
|
||
</div>
|
||
`;
|
||
sidepanel.dataset.open = 'true';
|
||
sidepanel.setAttribute('aria-hidden', 'false');
|
||
scrim.dataset.open = 'true';
|
||
}
|
||
function closePanel() {
|
||
sidepanel.dataset.open = 'false';
|
||
sidepanel.setAttribute('aria-hidden', 'true');
|
||
scrim.dataset.open = 'false';
|
||
}
|
||
document.getElementById('sidepanelClose').addEventListener('click', closePanel);
|
||
scrim.addEventListener('click', closePanel);
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closePanel(); });
|
||
|
||
/* -------------------------------------------------- TOP RISKS */
|
||
const topRisksEl = document.getElementById('topRisks');
|
||
if (topRisksEl) {
|
||
const sorted = [...data.threats]
|
||
.map(t => ({...t, score: t.sannsynlighet*t.konsekvens, residualScore: t.restrisiko.sannsynlighet*t.restrisiko.konsekvens}))
|
||
.sort((a,b) => b.score - a.score)
|
||
.slice(0,5);
|
||
sorted.forEach((t, i) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'top-risk';
|
||
li.innerHTML = `
|
||
<span class="top-risk__rank">${String(i+1).padStart(2,'0')}</span>
|
||
<span class="top-risk__score" data-zone="${zoneFromScore(t.score)}">${t.score}</span>
|
||
<span>
|
||
<div class="top-risk__id">${t.id}</div>
|
||
<div class="top-risk__title">${t.tittel}</div>
|
||
</span>
|
||
<span class="top-risk__delta">${t.score} → ${t.residualScore}</span>
|
||
`;
|
||
li.addEventListener('click', () => openThreatPanel(t.id));
|
||
topRisksEl.appendChild(li);
|
||
});
|
||
}
|
||
})();
|