ktg-plugin-marketplace/shared/playground-examples/ros-app.js
Kjell Tore Guttormsen 4a2bf3567a 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.
2026-05-02 06:59:19 +02:00

393 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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);
});
}
})();