feat(shared): add Playground design system v0.1 with Tier 1+2 components

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.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-02 06:59:19 +02:00
commit 4a2bf3567a
16 changed files with 6065 additions and 0 deletions

View file

@ -0,0 +1,393 @@
/* 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);
});
}
})();