diff --git a/README.md b/README.md
index 397ab7c..5c8d65f 100644
--- a/README.md
+++ b/README.md
@@ -192,7 +192,14 @@ Key commands: `/architect`, `/architect:ros`, `/architect:security`, `/architect
12 specialized agents · 24 commands · 5 skills (387 reference docs) · 2 hooks · sitemap-based KB monitoring
-**Playground:** `playground/ms-ai-architect-playground.html` — interactive 5-step pipeline (Intake → Explore → Configure → Review → Export). Vendored copy of the shared design-system at `playground/vendor/`, kept in sync via `scripts/sync-design-system.mjs ms-ai-architect`. Standalone — opens from `file://` without server or marketplace dependency.
+**Playground (v3, 2026-05-03):** Replaces the prior v2 5-step pipeline with a multi-surface decision-builder + report viewer. The single-file HTML app lives at `playground/ms-ai-architect-playground.html` (~3870 lines).
+
+- **4 surfaces:** Onboarding (18 shared organization fields prefill all command forms) → Home (project list + 3 entry tracks) → Catalog (24 commands grouped in 5 expansion categories with search) → Project (per-project tabs, command-form prefill, paste-back report import + visualization)
+- **Persistence:** IndexedDB primary + localStorage fallback, schema-versioned (`STATE_KEY = 'ms-ai-architect-state-v1'`) with eager migrations pipeline
+- **17 inline report renderers** routed via canonical archetype-routing table: classify pyramid, ROS/security 5×5 + 6×5 risk matrices, radar, findings tables, cost distribution, capability matrix, phased plans, decision-record envelopes
+- **Light/dark theme toggle** persisted in `localStorage('ms-ai-architect-theme')`, FOUC-safe via `
`-bootstrap script
+- **Validation:** 240 PASS combined (`bash tests/run-e2e.sh --playground` runs static-structure + parser-fixture suites)
+- **Vendored design-system** at `playground/vendor/`, kept in sync via `scripts/sync-design-system.mjs ms-ai-architect`. Standalone — opens from `file://` without server or marketplace dependency.
→ [Full documentation](plugins/ms-ai-architect/README.md)
diff --git a/plugins/ms-ai-architect/CLAUDE.md b/plugins/ms-ai-architect/CLAUDE.md
index 5bd550f..79bd83c 100644
--- a/plugins/ms-ai-architect/CLAUDE.md
+++ b/plugins/ms-ai-architect/CLAUDE.md
@@ -172,40 +172,46 @@ claude --plugin ./plugins/ms-ai-architect
/architect:help
```
-## Playground
+## Playground (v3)
-Interaktiv 5-stegs arkitektur-pipeline for Microsoft AI-beslutninger.
+Interaktiv decision-builder + rapport-viewer for Microsoft AI-beslutninger. Erstatter v2 5-stegs-pipelinen med en multi-surface-app som persisterer state og visualiserer importerte rapporter inline. Spec: v3-arkitektur dokumentert under `.claude/projects/2026-05-03-playground-v3-architecture/`.
-- **Fil:** `playground/ms-ai-architect-playground.html` (~1990 linjer)
-- **Spec:** `docs/playground-v2-spec.md`
-- **Build:** `playground/build/` (7 deler, brukes kun under utvikling — slettes etter assembly)
-- **Innhold:** 11 Azure AI-tjenester, 8 kategorier, 76 kapabiliteter, 8 scenarioer, 9 command pipelines
-- **5-stegs pipeline:** Intake (wizard) -> Utforsk (filtrert katalog) -> Konfigurer (parametere + compliance) -> Gjennomgang (cost P10/P50/P90 + risiko) -> Eksporter (4 formater)
-- **3 brukernivaer:** "Guide meg" (wizard), "La meg utforske" (browse), "Jeg vet hva jeg vil" (direkte)
-- **4 eksport-formater:** Strukturert prompt, Command pipeline med per-command copy, Markdown brief, JSON Decision Record
-- **Data extensions (vs v1):** `skill` (citizen/pro/devops), `setupDays`, `userRec` per item + `COMMAND_PIPELINES` per scenario
+- **Fil:** `playground/ms-ai-architect-playground.html` (~3870 linjer, single-file v3-arkitektur)
+- **4 surfaces:** Onboarding (18 felles felt fra `/architect:onboard`) → Home (prosjekt-liste + 3 entry-tracks) → Catalog (24 commands gruppert i 5 expansion-grupper med søk) → Project (per-prosjekt tabs, command-form-prefill fra felles state, paste-back-import med rapport-visualisering)
+- **Persistens:** IndexedDB-primær med localStorage-fallback. Schema-versjonert (`STATE_KEY = 'ms-ai-architect-state-v1'`) med eager `MIGRATIONS`-pipeline for fremtidige skjema-endringer.
+- **17 rapport-renderers:** Hver rapport-produserende command har én markdown-parser (markdown → struktur) og én renderer (struktur → HTML-visualisering: pyramide, matrix, radar, findings, distribution, capability-matrix, m.fl.) rutet via en kanonisk archetype-routing-tabell.
+- **Theme:** Mørk default + lys theme-toggle, persistert i `localStorage('ms-ai-architect-theme')`. Theme-bootstrap-script i `` unngår FOUC.
+- **Eksport/import:** JSON Decision Record-envelope (Blob + FileReader), schema-versjon-bevisst på import.
-### Vendored design-system (2026-05-03)
+### Validering
+
+| Test | Kommando | Dekning |
+|------|----------|---------|
+| Statisk struktur | `bash tests/test-playground-v3.sh` | 170 PASS — vendored CSS, surfaces, 24 commands, 14 parsere, 17 renderers, design-system-klasser, action-handlers |
+| Parser-fixtures | `bash tests/test-playground-parsers.sh` | 70 PASS — 17 fixtures × parser-routing |
+| Kombinert (E2E) | `bash tests/run-e2e.sh --playground` | begge over |
+| Manuell A11Y QA | Se `playground/MANUAL-CHECKLIST.md` | 10 seksjoner inkl. axe-core-kjøring per surface |
+| A11Y-rapport | `playground/A11Y-RAPPORT.md` | Skjelett — fylles ut etter kjøring |
+
+### Vendored design-system
Playground laster CSS fra `playground/vendor/playground-design-system/` — en vendored
kopi av marketplace-rotens `shared/playground-design-system/`. Dette holder pluginen
**standalone**: HTML-filen kan åpnes fra `file://` uavhengig av marketplace-roten.
-- **Sync-kilde:** `shared/playground-design-system/` (commit `f1fecf3` på sync-tidspunktet)
- **Sync-skript:** `node scripts/sync-design-system.mjs ms-ai-architect` (ved marketplace-rot)
- **Drift-deteksjon:** `MANIFEST.json` lagrer SHA-256 per fil. Re-sync feiler hvis
vendored fil er endret lokalt — `--force` overstyrer.
- **Lastes i HTML:** ``-tags til `fonts.css`, `tokens.css`, `base.css`,
`components.css`, `components-tier2.css`, `components-tier3.css`,
`components-tier3-supplement.css` (i den rekkefølgen).
-- **Legacy var-shim:** Inline `
+ /* Catalog (Step 9) */
+ .catalog-header { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-3) 0 var(--space-4); }
+ .catalog-header h1 { font-size: var(--font-size-2xl); margin: 0; }
+ .catalog-header p { color: var(--color-text-secondary); margin: 0; max-width: 70ch; }
+ .catalog-toolbar { display: flex; gap: var(--space-3); align-items: center; margin-bottom: var(--space-4); flex-wrap: wrap; }
+ .catalog-toolbar .input { max-width: 480px; flex: 1 1 280px; }
+ .catalog-toolbar__count { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
+ .catalog-groups { display: flex; flex-direction: column; gap: var(--space-3); }
+ .catalog-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3); padding: var(--space-2) 0; }
+ .catalog-card { background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
+ .catalog-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
+ .catalog-card__title { font-size: var(--font-size-md); margin: 0; font-weight: var(--font-weight-semibold); }
+ .catalog-card__desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 4px 0 0; }
+ .catalog-card__pill { padding: 2px 8px; background: var(--color-bg-soft); color: var(--color-text-secondary); border-radius: var(--radius-sm); font-size: 10px; font-weight: var(--font-weight-medium); flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.04em; }
+ .catalog-card__meta { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
+ .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); }
+
-
-
-
- Azure AI Architecture Playground
- Fra problem til handlingsplan. Velg modus, bygg arkitekturen din, og eksporter en komplett command-pipeline for Claude.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Hva slags organisasjon er du?
-
Dette styrer compliance-krav og lisensanbefalinger.
-
-
🏛
Statlig etat
Departement, direktorat, tilsyn
-
🏠
Kommune
Kommune eller bydel
-
🗺
Fylkeskommune
Regional forvaltning
-
🎓
Universitet/hogskole
UH-sektor
-
🏥
Helsevesen
Sykehus, helseforetak
-
🏢
Privat virksomhet
Naringsliv
-
-
-
-
-
-
-
Hvor stor er organisasjonen?
-
Pavirker lisenskostnad og skaleringsanbefalinger.
-
-
-
-
-
-
-
-
-
-
-
-
-
Hva skal du bygge?
-
Velg et scenario for a fa en anbefalt handlekurv, eller skriv fritekst.
-
-
-
-
-
-
-
-
-
-
-
-
-
Siste detaljer
-
Valgfritt — disse forbedrer kostnadsestimat og command-argumenter.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Konfigurer arkitekturen
- Juster parametere og sjekk compliance-status for dine valg.
-
-
-
-
-
-
-
-
-
- Gjennomgang
- Oversikt over arkitekturen, kostnader, compliance og risiko.
-
-
-
-
-
-
-
-
-
- Eksporter
- Velg format og ta med deg arkitekturbeslutningen videre.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Klikk pa kapabiliteter for a legge dem i handlekurven.
-
-
-
+ function unmountModal() {
+ const existing = document.querySelector('[data-modal-root]');
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
+ }
+
+ function renderNewProjectModalHtml() {
+ const scenarioOptions = SCENARIOS.map(function (s, i) {
+ return (
+ ''
+ );
+ }).join('');
+ return (
+ '' +
+ '
' +
+ '
Nytt prosjekt
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ 'Scenario-tagging' +
+ '
' +
+ 'Brukes for sammenligning og pipeline-anbefalinger.' +
+ '' +
+ '
' +
+ '
Mangler input
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
'
+ );
+ }
+
+ function renderDeleteProjectModalHtml(project) {
+ const reportCount = projectReportCount(project);
+ return (
+ '' +
+ '
' +
+ '
Slett prosjekt?
' +
+ '
' +
+ '
Bekreft sletting
' +
+ '
' +
+ '
Dette fjerner prosjektet ' + escapeHtml(project.name) + ' og ' + reportCount + ' importert' + (reportCount === 1 ? '' : 'e') + ' rapport' + (reportCount === 1 ? '' : 'er') + '. Handlingen kan ikke angres.
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
'
+ );
+ }
+
+ // ---- Sub-card rendering ----
+
+ function renderCommandSubCard(cmd, projectId) {
+ const titleHtml = (
+ '' +
+ '
' +
+ '
' + escapeHtml(cmd.label) + '
' +
+ '
' + escapeHtml(cmd.description) + '
' +
+ '
' +
+ '
/architect:' + escapeHtml(cmd.id) + '' +
+ '
'
+ );
+
+ const formZone = (
+ '' +
+ '
Skjema
' +
+ '
' +
+ renderCommandForm(cmd.id, { context: 'project', projectId: projectId, scope: 'p' }) +
+ '
' +
+ '
'
+ );
+
+ if (!cmd.produces_report) {
+ // Verktøy: skjema-zone + .guide-panel--info notis
+ const toolNotice = (
+ '' +
+ '
' +
+ '
i
' +
+ '
' +
+ '
Verktøy
' +
+ '
Dette er et verktøy. Ingen rapport-import — bruk skjemaet til å bygge en pipeline-streng som kjøres i terminalen.
' +
+ '
' +
+ '
' +
+ '
'
+ );
+ return (
+ '' +
+ titleHtml +
+ formZone +
+ toolNotice +
+ ''
+ );
+ }
+
+ // Rapport-produserende: skjema-zone + paste-import-zone + report-zone
+ const pasteZone = (
+ '' +
+ '
Lim inn rapport-output
' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ 'Routes via PARSERS[' + escapeHtml(cmd.report_archetype || '?') + '] → ' + escapeHtml(cmd.renderer || '?') + ' (Step 11/12).' +
+ '
' +
+ '
' +
+ '
'
+ );
+
+ const reportZone = (
+ '' +
+ '
Visualisering
' +
+ '
' +
+ '
'
+ );
+
+ return (
+ '' +
+ titleHtml +
+ formZone +
+ pasteZone +
+ reportZone +
+ ''
+ );
+ }
+
+ function renderProjectSurface() {
+ const root = getSurfaceEl('project');
+ if (!root) return;
+
+ const project = findProject(store.state.activeProjectId);
+ if (!project) {
+ // Mistet aktivt prosjekt — fall tilbake til hjem.
+ navigate('home');
+ return;
+ }
+
+ const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
+ const reportFilled = projectReportCount(project);
+
+ const scenarioChips = (project.scenarios || []).map(function (sid) {
+ const s = SCENARIOS.find(function (x) { return x.id === sid; });
+ return '';
+ }).join('');
+ const dateChip = '';
+ const progressChip = '';
+
+ const headerHtml = (
+ ''
+ );
+
+ // Tabs per CATALOG.categories
+ const tabsHtml = '' + CATALOG.categories.map(function (cat) {
+ const isActive = currentProjectTab === cat.id;
+ return (
+ ''
+ );
+ }).join('') + '
';
+
+ // Render ALLE kategori-paneler i DOM (med [hidden] på inaktive). Dette
+ // sikrer at querySelectorAll('[data-paste-import]') matcher alle 17
+ // rapport-produserende commands uavhengig av aktiv tab.
+ const panelsHtml = CATALOG.categories.map(function (cat) {
+ const isActive = currentProjectTab === cat.id;
+ const cards = CATALOG.commands
+ .filter(function (c) { return c.category === cat.id; })
+ .map(function (c) { return renderCommandSubCard(c, project.id); }).join('');
+ return (
+ '' +
+ cards +
+ '
'
+ );
+ }).join('');
+
+ root.innerHTML = (
+ renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
+ '' +
+ headerHtml +
+ tabsHtml +
+ panelsHtml +
+ '
'
+ );
+ }
+
+ // ============================================================
+ // CATALOG SURFACE (Step 9)
+ // ============================================================
+ //
+ // 24 commands gruppert i 5 .expansion-grupper (CATALOG.categories) med
+ // søke-input som filtrerer på id+label+description+argument_hint.
+ // Hver kategori-expansion rendrer en .catalog-cards-grid med kort.
+ // "Åpne skjema" på et kort åpner renderCommandForm() i modal.
+ //
+ // Søk: input-event oppdaterer modul-lokal catalogSearchQuery og
+ // re-rendrer kun groups-containeren (bevarer fokus/cursor i søkefeltet).
+ // Når query er ikke-tom forces alle expansions åpne. I tom-state er
+ // 'regulatory' åpen som standard (mest brukt entry-point).
+ //
+ // Verktøy-commands får .catalog-tool-notice "Verktøy"-pill + samme
+ // skjema-modal — ingen rapport-import (parser/renderer hopper dem over).
+
+ let catalogSearchQuery = '';
+
+ function catalogMatches(cmd, q) {
+ if (!q) return true;
+ const hay = (
+ (cmd.id || '') + ' ' +
+ (cmd.label || '') + ' ' +
+ (cmd.description || '') + ' ' +
+ (cmd.argument_hint || '')
+ ).toLowerCase();
+ return hay.indexOf(q) >= 0;
+ }
+
+ function renderCatalogCardHtml(cmd) {
+ const isVerktoy = !cmd.produces_report;
+ const pill = isVerktoy
+ ? 'Verktøy'
+ : 'Rapport';
+ const hintHtml = cmd.argument_hint
+ ? '' + escapeHtml(cmd.argument_hint) + ''
+ : '';
+ const verktoyNotice = isVerktoy
+ ? 'Verktøy — ingen rapport-import. Skjema bygger pipeline-streng som kjøres i terminalen.
'
+ : '';
+ return (
+ '' +
+ '' +
+ '
' +
+ '
' + escapeHtml(cmd.label) + '
' +
+ '
' + escapeHtml(cmd.description) + '
' +
+ '
' +
+ pill +
+ '
' +
+ '' +
+ '/architect:' + escapeHtml(cmd.id) + '' +
+ hintHtml +
+ '
' +
+ verktoyNotice +
+ '' +
+ '' +
+ '
' +
+ ''
+ );
+ }
+
+ function renderCatalogGroupsHtml() {
+ const q = (catalogSearchQuery || '').trim().toLowerCase();
+ return CATALOG.categories.map(function (cat) {
+ const cmds = CATALOG.commands.filter(function (c) {
+ return c.category === cat.id && catalogMatches(c, q);
+ });
+ const cardsHtml = cmds.map(renderCatalogCardHtml).join('');
+ // Force-open når aktiv søk-query har treff. Ellers: 'regulatory' åpen som default.
+ const expanded = q ? (cmds.length > 0 ? 'true' : 'false') : (cat.id === 'regulatory' ? 'true' : 'false');
+ const subLabel = cmds.length === cat.count
+ ? cat.count + ' commands'
+ : cmds.length + ' / ' + cat.count + ' commands';
+ const body = cmds.length > 0
+ ? '' + cardsHtml + '
'
+ : 'Ingen treff i denne kategorien.
';
+ return (
+ '' +
+ '' +
+ '' +
+ '
' + body + '
' +
+ '
' +
+ ''
+ );
+ }).join('');
+ }
+
+ function renderCatalogSurface() {
+ const root = getSurfaceEl('catalog');
+ if (!root) return;
+ const q = (catalogSearchQuery || '').trim().toLowerCase();
+ const totalMatches = CATALOG.commands.filter(function (c) { return catalogMatches(c, q); }).length;
+ const countText = totalMatches + ' av ' + CATALOG.commands.length + ' treff' + (q ? ' for «' + escapeHtml(catalogSearchQuery) + '»' : '');
+ root.innerHTML = (
+ renderTopbar('Katalog') +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '' + countText + '' +
+ '
' +
+ '
' + renderCatalogGroupsHtml() + '
' +
+ '
'
+ );
+ }
+
+ function refreshCatalogResults() {
+ const root = getSurfaceEl('catalog');
+ if (!root) return;
+ const groupsEl = root.querySelector('[data-catalog-groups]');
+ if (groupsEl) groupsEl.innerHTML = renderCatalogGroupsHtml();
+ const countEl = root.querySelector('[data-catalog-count]');
+ if (countEl) {
+ const q = (catalogSearchQuery || '').trim().toLowerCase();
+ const totalMatches = CATALOG.commands.filter(function (c) { return catalogMatches(c, q); }).length;
+ countEl.textContent = totalMatches + ' av ' + CATALOG.commands.length + ' treff' + (q ? ' for «' + catalogSearchQuery + '»' : '');
+ }
+ }
+
+ function renderCatalogFormModalHtml(cmd) {
+ const formHtml = renderCommandForm(cmd.id, { context: 'modal', scope: 'm' });
+ const verktoyBanner = !cmd.produces_report
+ ? (
+ '' +
+ '
i
' +
+ '
' +
+ '
Verktøy
' +
+ '
Dette er et verktøy. Skjemaet bygger en pipeline-streng — ingen rapport-import.
' +
+ '
' +
+ '
'
+ )
+ : '';
+ return (
+ '' +
+ '
' +
+ '
' +
+ '
' + escapeHtml(cmd.label) + '
' +
+ '
' + escapeHtml(cmd.description) + '
' +
+ '
/architect:' + escapeHtml(cmd.id) + '' +
+ '
' +
+ verktoyBanner +
+ '
' + formHtml + '
' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
'
+ );
+ }
+
+ // ============================================================
+ // MARKDOWN PARSERS (Step 11)
+ // ============================================================
+ //
+ // 14 parser-arketyper per kanonisk routing-tabell. Hver parser tar
+ // markdown-streng og returnerer { ok: true, data: {...} } eller
+ // { ok: false, errors: [{section, reason}] }. Parsers er tolerante
+ // (kaster aldri unntak) — tom/uventet input gir strukturert feil.
+ //
+ // Routing: PARSERS[archetype] for oppslag i handlePasteImport.
+
+ // ---- Felles helpers ----
+
+ function parseTableRow(line) {
+ const inner = line.replace(/^\|/, '').replace(/\|$/, '');
+ return inner.split('|').map(function (c) { return c.trim(); });
+ }
+
+ function parseTable(md, anchorRegex) {
+ if (typeof md !== 'string') return null;
+ let body = md;
+ if (anchorRegex) {
+ const m = anchorRegex.exec(md);
+ if (!m) return null;
+ body = md.slice(m.index + m[0].length);
+ }
+ const lines = body.split(/\r?\n/);
+ for (let i = 0; i < lines.length - 1; i++) {
+ const line = lines[i].trim();
+ const next = (lines[i + 1] || '').trim();
+ if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
+ const headers = parseTableRow(line);
+ const rows = [];
+ for (let j = i + 2; j < lines.length; j++) {
+ const rowLine = lines[j].trim();
+ if (rowLine.indexOf('|') !== 0) break;
+ const cells = parseTableRow(rowLine);
+ if (cells.length === 0) break;
+ const row = {};
+ for (let k = 0; k < headers.length; k++) {
+ row[headers[k]] = (cells[k] || '').trim();
+ }
+ rows.push(row);
+ }
+ return { headers: headers, rows: rows };
+ }
+ }
+ return null;
+ }
+
+ function parseSections(md) {
+ if (typeof md !== 'string') return [];
+ const sections = [];
+ const lines = md.split(/\r?\n/);
+ let current = null;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const m = /^##\s+(.+)$/.exec(line);
+ if (m && line.charAt(2) === ' ') { // exactly two #
+ if (current) sections.push(current);
+ current = { heading: m[1].trim(), body: '' };
+ } else if (current) {
+ current.body += (current.body ? '\n' : '') + line;
+ }
+ }
+ if (current) sections.push(current);
+ return sections.map(function (s) {
+ return { heading: s.heading, body: s.body.trim() };
+ });
+ }
+
+ function extractField(md, label) {
+ if (typeof md !== 'string') return null;
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const re = new RegExp('^\\s*' + escaped + '\\s*:\\s*(.+)$', 'mi');
+ const m = re.exec(md);
+ return m ? m[1].trim() : null;
+ }
+
+ function intOrZero(s) {
+ if (typeof s !== 'string') return 0;
+ const v = parseInt(s.replace(/[^\d-]/g, ''), 10);
+ return isNaN(v) ? 0 : v;
+ }
+
+ function emptyInput(md) {
+ return !md || typeof md !== 'string' || !md.trim();
+ }
+
+ // ---- 14 archetype parsers ----
+
+ function parseAiAct(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const errors = [];
+ const sections = parseSections(md);
+
+ let risk_level = extractField(md, 'Risk-level') || extractField(md, 'Risikonivå');
+ if (!risk_level) {
+ const sec = sections.find(function (s) { return /risikoniv|risk.level/i.test(s.heading); });
+ if (sec) {
+ const firstLine = sec.body.split(/\r?\n/)[0] || '';
+ risk_level = firstLine.replace(/^Risk-level:\s*/i, '').replace(/^Risikonivå:\s*/i, '').trim();
+ }
+ }
+ if (!risk_level) errors.push({ section: 'risk_level', reason: 'Fant ikke risikonivå' });
+
+ const role = extractField(md, 'Rolle') || extractField(md, 'Role') || '';
+ if (!role) errors.push({ section: 'role', reason: 'Fant ikke rolle' });
+
+ let reasoning = extractField(md, 'Reasoning') || extractField(md, 'Begrunnelse') || '';
+ if (!reasoning) {
+ const sec = sections.find(function (s) { return /begrunnelse|reasoning/i.test(s.heading); });
+ if (sec) reasoning = sec.body;
+ }
+
+ const obligations = [];
+ const oblSec = sections.find(function (s) { return /forpliktelser|obligations/i.test(s.heading); });
+ if (oblSec) {
+ oblSec.body.split(/\r?\n/).forEach(function (line) {
+ const m = /^[-*]\s+(.+)$/.exec(line.trim());
+ if (m) obligations.push(m[1].trim());
+ });
+ }
+
+ if (errors.length > 0) return { ok: false, errors: errors };
+ return {
+ ok: true,
+ data: {
+ risk_level: (risk_level || '').toLowerCase(),
+ role: role,
+ reasoning: reasoning,
+ obligations: obligations
+ }
+ };
+ }
+
+ function parseRequirements(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const tbl = parseTable(md);
+ if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen krav-tabell funnet' }] };
+ const reqKey = tbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || tbl.headers[0];
+ const statusKey = tbl.headers.find(function (h) { return /status/i.test(h); }) || tbl.headers[1];
+ const sourceKey = tbl.headers.find(function (h) { return /kilde|source|art/i.test(h); }) || tbl.headers[2];
+ const items = tbl.rows.map(function (row) {
+ return {
+ requirement: row[reqKey] || '',
+ status: (row[statusKey] || '').toLowerCase().trim(),
+ source_article: row[sourceKey] || ''
+ };
+ });
+ return { ok: true, data: { items: items } };
+ }
+
+ function parseTextDocument(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const sections = parseSections(md);
+ if (!sections.length) {
+ return { ok: true, data: { sections: [{ heading: 'Innhold', body: md.trim() }] } };
+ }
+ return { ok: true, data: { sections: sections } };
+ }
+
+ function parseFria(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const tbl = parseTable(md);
+ if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen rettighet-tabell funnet' }] };
+ const nameKey = tbl.headers.find(function (h) { return /rettighet|right/i.test(h); }) || tbl.headers[0];
+ const impactKey = tbl.headers.find(function (h) { return /impact|påvirkning/i.test(h); }) || tbl.headers[1];
+ const mitigKey = tbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }) || tbl.headers[2];
+ const rights = tbl.rows.map(function (row) {
+ return {
+ name: row[nameKey] || '',
+ impact: intOrZero(row[impactKey] || '0'),
+ mitigation: row[mitigKey] || ''
+ };
+ });
+ return { ok: true, data: { rights: rights } };
+ }
+
+ function parseConformityChecklist(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const checklistTbl = parseTable(md, /##\s*Sjekkliste/i) || parseTable(md);
+ if (!checklistTbl) return { ok: false, errors: [{ section: 'checklist', reason: 'Ingen sjekkliste-tabell funnet' }] };
+ const reqKey = checklistTbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || checklistTbl.headers[0];
+ const statusKey = checklistTbl.headers.find(function (h) { return /status/i.test(h); }) || checklistTbl.headers[1];
+ const evidKey = checklistTbl.headers.find(function (h) { return /bevis|evidence/i.test(h); }) || checklistTbl.headers[2];
+ const checklist = checklistTbl.rows.map(function (row) {
+ return {
+ requirement: row[reqKey] || '',
+ status: (row[statusKey] || '').toLowerCase().trim(),
+ evidence: row[evidKey] || ''
+ };
+ });
+ const deadlinesTbl = parseTable(md, /##\s*Frister/i);
+ const deadlines = deadlinesTbl ? deadlinesTbl.rows.map(function (row) {
+ const dateKey = deadlinesTbl.headers.find(function (h) { return /dato|date/i.test(h); }) || deadlinesTbl.headers[0];
+ const mileKey = deadlinesTbl.headers.find(function (h) { return /milepæl|milestone/i.test(h); }) || deadlinesTbl.headers[1];
+ const stKey = deadlinesTbl.headers.find(function (h) { return /status/i.test(h); }) || deadlinesTbl.headers[2];
+ return {
+ date: row[dateKey] || '',
+ milestone: row[mileKey] || '',
+ status: (row[stKey] || '').toLowerCase().trim()
+ };
+ }) : [];
+ return { ok: true, data: { checklist: checklist, deadlines: deadlines } };
+ }
+
+ function parseMatrixRisk(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const matrixTbl = parseTable(md, /Risikomatrise.*5/i) || parseTable(md);
+ if (!matrixTbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen risikomatrise funnet' }] };
+ const labelKey = matrixTbl.headers[0];
+ const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
+ const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
+ const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
+ const matrix_cells = matrixTbl.rows.map(function (row) {
+ return {
+ label: row[labelKey] || '',
+ prob: intOrZero(row[sannKey] || '0'),
+ cons: intOrZero(row[konsKey] || '0'),
+ score: intOrZero(row[scoreKey] || '0')
+ };
+ });
+ const threatsTbl = parseTable(md, /##\s*Trusler/i);
+ const threats = threatsTbl ? threatsTbl.rows.map(function (row) {
+ const idKey = threatsTbl.headers[0];
+ const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1];
+ const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
+ const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
+ return {
+ id: row[idKey] || '',
+ description: row[descKey] || '',
+ severity: (row[sevKey] || '').toLowerCase().trim(),
+ mitigation: row[mitKey] || ''
+ };
+ }) : [];
+ const radarTbl = parseTable(md, /Radar.akser/i);
+ const radar_axes = radarTbl ? radarTbl.rows.map(function (row) {
+ const akseKey = radarTbl.headers.find(function (h) { return /akse|axis/i.test(h); }) || radarTbl.headers[0];
+ const scKey = radarTbl.headers.find(function (h) { return /score/i.test(h); }) || radarTbl.headers[1];
+ return {
+ name: row[akseKey] || '',
+ score: intOrZero(row[scKey] || '0')
+ };
+ }) : null;
+ return { ok: true, data: { matrix_cells: matrix_cells, threats: threats, radar_axes: radar_axes } };
+ }
+
+ function parseMatrixRisk6x5(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const dimsTbl = parseTable(md, /Score per dimensjon/i);
+ if (!dimsTbl) return { ok: false, errors: [{ section: 'dimensions', reason: 'Ingen dimensjon-tabell funnet' }] };
+ const dimNameKey = dimsTbl.headers.find(function (h) { return /dimensjon/i.test(h); }) || dimsTbl.headers[0];
+ const dimScoreKey = dimsTbl.headers.find(function (h) { return /score/i.test(h); }) || dimsTbl.headers[1];
+ const dimVurdKey = dimsTbl.headers.find(function (h) { return /vurdering/i.test(h); });
+ const dimensions = dimsTbl.rows.map(function (row) {
+ return {
+ name: row[dimNameKey] || '',
+ score: intOrZero(row[dimScoreKey] || '0'),
+ assessment: row[dimVurdKey] || ''
+ };
+ });
+ const matrixTbl = parseTable(md, /Risikomatrise.*6/i);
+ const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) {
+ const labelKey = matrixTbl.headers[0];
+ const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
+ const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
+ const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
+ return {
+ label: row[labelKey] || '',
+ prob: intOrZero(row[sannKey] || '0'),
+ cons: intOrZero(row[konsKey] || '0'),
+ score: intOrZero(row[scoreKey] || '0')
+ };
+ }) : [];
+ const findingsTbl = parseTable(md, /##\s*Funn/i);
+ const findings = findingsTbl ? findingsTbl.rows.map(function (row) {
+ const idKey = findingsTbl.headers[0];
+ const sevKey = findingsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
+ const locKey = findingsTbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
+ const recKey = findingsTbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
+ return {
+ id: row[idKey] || '',
+ severity: (row[sevKey] || '').toLowerCase().trim(),
+ location: row[locKey] || '',
+ recommendation: row[recKey] || ''
+ };
+ }) : [];
+ return {
+ ok: true,
+ data: {
+ dimensions: dimensions,
+ matrix_cells: matrix_cells,
+ findings: findings,
+ scores: dimensions.map(function (d) { return d.score; })
+ }
+ };
+ }
+
+ function parseFindings(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const tbl = parseTable(md, /##\s*Funn/i) || parseTable(md);
+ if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen funn-tabell funnet' }] };
+ const idKey = tbl.headers[0];
+ const sevKey = tbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
+ const locKey = tbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
+ const recKey = tbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
+ const findings = tbl.rows.map(function (row) {
+ return {
+ id: row[idKey] || '',
+ severity: (row[sevKey] || '').toLowerCase().trim(),
+ location: row[locKey] || '',
+ recommendation: row[recKey] || ''
+ };
+ });
+ return { ok: true, data: { findings: findings } };
+ }
+
+ function parseCostDistribution(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const distTbl = parseTable(md, /Distribusjon/i);
+ if (!distTbl) return { ok: false, errors: [{ section: 'distribution', reason: 'Ingen distribusjons-tabell funnet' }] };
+ const persKey = distTbl.headers.find(function (h) { return /persentil|percentile/i.test(h); }) || distTbl.headers[0];
+ const monthlyKey = distTbl.headers.find(function (h) { return /månedlig|monthly/i.test(h); }) || distTbl.headers[1];
+ const yearlyKey = distTbl.headers.find(function (h) { return /årlig|yearly/i.test(h); });
+ let p10 = null, p50 = null, p90 = null;
+ distTbl.rows.forEach(function (row) {
+ const monthly = intOrZero(row[monthlyKey] || '0');
+ const yearly = yearlyKey ? intOrZero(row[yearlyKey] || '0') : null;
+ const entry = { monthly: monthly, yearly: yearly };
+ const tag = (row[persKey] || '').toUpperCase();
+ if (/P10|P\.10|P 10/.test(tag)) p10 = entry;
+ else if (/P50|P\.50|P 50/.test(tag)) p50 = entry;
+ else if (/P90|P\.90|P 90/.test(tag)) p90 = entry;
+ });
+ const monthlyTbl = parseTable(md, /Månedlig fordeling/i);
+ const monthly_breakdown = monthlyTbl ? monthlyTbl.rows.map(function (row) {
+ const compKey = monthlyTbl.headers[0];
+ const costKey = monthlyTbl.headers[1];
+ return {
+ component: row[compKey] || '',
+ cost: intOrZero(row[costKey] || '0')
+ };
+ }) : [];
+ const tcoTbl = parseTable(md, /TCO/i);
+ const tco_table = tcoTbl ? tcoTbl.rows : [];
+ return {
+ ok: true,
+ data: {
+ p10: p10, p50: p50, p90: p90,
+ monthly_breakdown: monthly_breakdown,
+ tco_table: tco_table,
+ tco_headers: tcoTbl ? tcoTbl.headers : []
+ }
+ };
+ }
+
+ function parseCapabilityMatrix(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const tbl = parseTable(md, /##\s*Matrise/i) || parseTable(md);
+ if (!tbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen matrise funnet' }] };
+ const capKey = tbl.headers[0];
+ const licenseNames = tbl.headers.slice(1);
+ const licenses = licenseNames.map(function (name) {
+ return { name: name, capabilities: [] };
+ });
+ tbl.rows.forEach(function (row) {
+ const capName = row[capKey];
+ licenseNames.forEach(function (licName, i) {
+ licenses[i].capabilities.push({
+ name: capName,
+ status: (row[licName] || '').toLowerCase().trim()
+ });
+ });
+ });
+ return { ok: true, data: { licenses: licenses } };
+ }
+
+ function parsePhasedPlan(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const phases = [];
+ const lines = md.split(/\r?\n/);
+ let current = null;
+ let bucket = null;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const phaseMatch = /^###\s+(?:Fase\s+\d+\s*[—-]\s*)?(.+?)\s*(?:\(.*\))?\s*$/i.exec(line.trim());
+ const isH3 = /^###\s+/.test(line);
+ const isH2 = /^##\s+/.test(line) && !isH3;
+ if (isH3 && phaseMatch) {
+ if (current) phases.push(current);
+ current = {
+ name: phaseMatch[1].trim(),
+ milestones: [],
+ success_criteria: [],
+ duration_weeks: null
+ };
+ bucket = null;
+ continue;
+ }
+ if (isH2) {
+ if (current) { phases.push(current); current = null; }
+ bucket = null;
+ continue;
+ }
+ if (!current) continue;
+ const trimmed = line.trim();
+ const durMatch = /^Varighet:\s*(\d+)\s*uke/i.exec(trimmed);
+ if (durMatch) {
+ current.duration_weeks = parseInt(durMatch[1], 10);
+ continue;
+ }
+ if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; }
+ if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; }
+ const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed);
+ if (bulletMatch && bucket && current[bucket]) {
+ current[bucket].push(bulletMatch[1].trim());
+ }
+ }
+ if (current) phases.push(current);
+
+ const risksTbl = parseTable(md, /##\s*Risiko/i);
+ const risks = risksTbl ? risksTbl.rows.map(function (row) {
+ const risikoKey = risksTbl.headers[0];
+ const sannKey = risksTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
+ const konsKey = risksTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
+ const tiltakKey = risksTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
+ return {
+ risk: row[risikoKey] || '',
+ probability: row[sannKey] || '',
+ consequence: row[konsKey] || '',
+ mitigation: row[tiltakKey] || ''
+ };
+ }) : [];
+
+ if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] };
+ return { ok: true, data: { phases: phases, risks: risks } };
+ }
+
+ function parseMarkdown(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const titleMatch = /^#\s+(.+)$/m.exec(md);
+ const title = titleMatch ? titleMatch[1].trim() : '';
+ const sections = parseSections(md);
+ // Frontmatter-style fields (Status, Date, Deciders) — typisk i ADR
+ const status = extractField(md, 'Status') || '';
+ const date = extractField(md, 'Date') || extractField(md, 'Dato') || '';
+ const deciders = extractField(md, 'Deciders') || extractField(md, 'Beslutningstakere') || '';
+ return { ok: true, data: { title: title, sections: sections, raw: md, status: status, date: date, deciders: deciders } };
+ }
+
+ function parseVerdict(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const verdictRaw = extractField(md, 'Verdict') || '';
+ const verdict = verdictRaw.toLowerCase().trim();
+ const sub = extractField(md, 'Sub') || '';
+ const sections = parseSections(md);
+ const ratSec = sections.find(function (s) { return /rationale|begrunnelse/i.test(s.heading); });
+ const rationale = ratSec ? ratSec.body : '';
+ const metricsTbl = parseTable(md, /Key Metrics|Nøkkelmetrikker/i);
+ const key_metrics = metricsTbl ? metricsTbl.rows : [];
+ const metrics_headers = metricsTbl ? metricsTbl.headers : [];
+ const nextSec = sections.find(function (s) { return /next steps|neste steg/i.test(s.heading); });
+ const next_steps = [];
+ if (nextSec) {
+ nextSec.body.split(/\r?\n/).forEach(function (line) {
+ const m = /^[-*]\s+(.+)$/.exec(line.trim());
+ if (m) next_steps.push(m[1].trim());
+ });
+ }
+ if (!verdict) return { ok: false, errors: [{ section: 'verdict', reason: 'Fant ikke "Verdict:"-linje' }] };
+ return {
+ ok: true,
+ data: {
+ verdict: verdict,
+ sub: sub,
+ rationale: rationale,
+ key_metrics: key_metrics,
+ metrics_headers: metrics_headers,
+ next_steps: next_steps
+ }
+ };
+ }
+
+ function parseComparison(md) {
+ if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
+ const subject1 = extractField(md, 'Subject 1') || '';
+ const subject2 = extractField(md, 'Subject 2') || '';
+ const tbl = parseTable(md, /##\s*Sammenligning|##\s*Comparison/i) || parseTable(md);
+ if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen sammenligningstabell funnet' }] };
+ const aspectKey = tbl.headers[0];
+ const v1Key = tbl.headers[1];
+ const v2Key = tbl.headers[2];
+ const winnerKey = tbl.headers[3];
+ const subjects = [subject1 || v1Key || '', subject2 || v2Key || ''];
+ const rows = tbl.rows.map(function (row) {
+ return {
+ aspect: row[aspectKey] || '',
+ value1: row[v1Key] || '',
+ value2: row[v2Key] || '',
+ winner: winnerKey ? (row[winnerKey] || '') : ''
+ };
+ });
+ return { ok: true, data: { subjects: subjects, rows: rows } };
+ }
+
+ // ---- PARSERS routing-objekt ----
+
+ const PARSERS = {
+ 'aiact': parseAiAct,
+ 'requirements-list': parseRequirements,
+ 'text-document': parseTextDocument,
+ 'fria': parseFria,
+ 'conformity-checklist': parseConformityChecklist,
+ 'matrix-risk': parseMatrixRisk,
+ 'matrix-risk-6x5': parseMatrixRisk6x5,
+ 'findings': parseFindings,
+ 'cost-distribution': parseCostDistribution,
+ 'capability': parseCapabilityMatrix,
+ 'phased-plan': parsePhasedPlan,
+ 'markdown': parseMarkdown,
+ 'verdict': parseVerdict,
+ 'comparison': parseComparison
+ };
+
+ // Eksponer for Verify-asserts og Step 12.
+ window.__PARSERS = PARSERS;
+ window.__parseTable = parseTable;
+ window.__parseSections = parseSections;
+ window.__extractField = extractField;
+
+ // ============================================================
+ // REPORT RENDERERS (Step 12)
+ // ============================================================
+ //
+ // 17 renderers per kanonisk archetype-routing-tabell. Hver renderer
+ // tar parsed data + slot DOM-element, og fyller slot.innerHTML med
+ // markup som matcher design-system BEM-klasser (.pyramide, .matrix,
+ // .findings, .rights-matrix, .capability-matrix, .distribution,
+ // .verdict-block, .pipeline-cockpit, .diff, .aiact-timeline).
+ //
+ // Routing: RENDERERS[command.renderer] for oppslag i handlePasteImport
+ // (under). Verktøy-commands (produces_report=false) får ingen renderer.
+
+ // ---- Felles helpers ----
+
+ function renderEmptyState() {
+ return '' +
+ '
i
' +
+ '
Ingen data å vise — tom eller ufullstendig parsing.
' +
+ '
';
+ }
+
+ function renderError(errors, slot) {
+ const items = (errors || []).map(function (e) {
+ return '' + escapeHtml(e.section || 'feil') + ': ' + escapeHtml(e.reason || 'Ukjent') + '';
+ }).join('');
+ slot.innerHTML =
+ '' +
+ '
Kunne ikke parse rapporten
' +
+ '
Justér markdown-format og lim inn på nytt.
' +
+ (items ? '
' : '') +
+ '
' +
+ '
';
+ }
+
+ function renderThreatsTable(threats) {
+ if (!threats || !threats.length) return '';
+ const rows = threats.map(function (t) {
+ return '| ' + escapeHtml(t.id || '') + ' | ' + escapeHtml(t.description || '') + ' | ' + escapeHtml(t.severity || '') + ' | ' + escapeHtml(t.mitigation || '') + ' |
';
+ }).join('');
+ return '| ID | Beskrivelse | Severity | Tiltak |
' + rows + '
';
+ }
+
+ function renderFindingsBlock(findings, label) {
+ const items = findings.map(function (f) {
+ return '' +
+ '' +
+ '' + escapeHtml(f.id || '') + '' +
+ '' + escapeHtml(f.recommendation || '') + '' +
+ 'Lokasjon: ' + escapeHtml(f.location || '—') + ' · Severity: ' + escapeHtml(f.severity || '—') + '' +
+ '';
+ }).join('');
+ return '';
+ }
+
+ function renderMatrixHtml(data, cons_max) {
+ cons_max = cons_max || 5;
+ const cells = data.matrix_cells || [];
+ const byPC = {};
+ cells.forEach(function (c) {
+ const k = c.prob + '_' + c.cons;
+ if (!byPC[k]) byPC[k] = [];
+ byPC[k].push(c);
+ });
+ const probSize = 5;
+ let html = 'Konsekvens
';
+ html += '
';
+ for (let cons = cons_max; cons >= 1; cons--) {
+ html += '
' + cons + '
';
+ for (let prob = 1; prob <= probSize; prob++) {
+ const score = prob * cons;
+ const items = byPC[prob + '_' + cons] || [];
+ const bubblesHtml = items.length
+ ? '
' +
+ items.slice(0, 3).map(function (it, i) {
+ return '' + (i + 1) + '';
+ }).join('') +
+ (items.length > 3 ? '+' + (items.length - 3) + '' : '') +
+ '
'
+ : '';
+ html += '
' +
+ '' + score + '' + bubblesHtml +
+ '
';
+ }
+ }
+ html += '
';
+ for (let prob = 1; prob <= probSize; prob++) {
+ html += '
' + prob + '
';
+ }
+ html += '
';
+ html += '
Sannsynlighet
';
+ html += '
';
+ return html;
+ }
+
+ function renderRadarSvg(axes) {
+ if (!axes || !axes.length) return '';
+ const N = axes.length;
+ const cx = 150, cy = 150, R = 100;
+ const points = axes.map(function (a, i) {
+ const angle = (i / N) * 2 * Math.PI - Math.PI / 2;
+ const r = R * (Math.max(0, Math.min(5, a.score)) / 5);
+ return (cx + r * Math.cos(angle)).toFixed(1) + ',' + (cy + r * Math.sin(angle)).toFixed(1);
+ }).join(' ');
+ const labels = axes.map(function (a, i) {
+ const angle = (i / N) * 2 * Math.PI - Math.PI / 2;
+ const x = cx + (R + 25) * Math.cos(angle);
+ const y = cy + (R + 25) * Math.sin(angle);
+ return '' + escapeHtml(a.name) + '';
+ }).join('');
+ const spokes = axes.map(function (a, i) {
+ const angle = (i / N) * 2 * Math.PI - Math.PI / 2;
+ const x = cx + R * Math.cos(angle);
+ const y = cy + R * Math.sin(angle);
+ return '';
+ }).join('');
+ return '' +
+ '
' +
+ '
';
+ }
+
+ // ---- Sub-batch A: Regulatory (6) ----
+
+ function renderAiActPyramid(data, slot) {
+ const norm = (data.risk_level || '').toLowerCase();
+ let activeTier = 'minimal';
+ if (/forbidden|uakseptabel|prohibited|unacceptable/.test(norm)) activeTier = 'forbidden';
+ else if (/høy|high|hoy/.test(norm)) activeTier = 'high';
+ else if (/begrenset|limited/.test(norm)) activeTier = 'limited';
+ else if (/minimal|low/.test(norm)) activeTier = 'minimal';
+
+ const tiers = [
+ { id: 'forbidden', label: 'Uakseptabel risiko (Art. 5)' },
+ { id: 'high', label: 'Høyrisiko (Art. 6 + Annex III)' },
+ { id: 'limited', label: 'Begrenset risiko (Art. 50)' },
+ { id: 'minimal', label: 'Minimal risiko' }
+ ];
+ const tiersHtml = tiers.map(function (t) {
+ const active = (t.id === activeTier);
+ const ariaCurrent = active ? ' aria-current="true"' : '';
+ const marker = active ? ' ← klassifisert' : '';
+ return '' +
+ '
' + escapeHtml(t.label) + '
' +
+ marker +
+ '
';
+ }).join('');
+
+ const obligationsHtml = (data.obligations || []).length
+ ? ''
+ : '';
+ const meta = '';
+ slot.innerHTML = '' + tiersHtml + '
' + meta + obligationsHtml;
+ }
+
+ function renderRequirements(data, slot) {
+ const sevForStatus = function (status) {
+ const s = (status || '').toLowerCase();
+ if (s === 'met') return 'low';
+ if (s === 'partial') return 'medium';
+ if (s === 'missing') return 'critical';
+ return 'info';
+ };
+ const items = (data.items || []).map(function (it, idx) {
+ return '' +
+ '' +
+ 'R-' + String(idx + 1).padStart(2, '0') + '' +
+ '' + escapeHtml(it.requirement) + '' +
+ 'Kilde: ' + escapeHtml(it.source_article || '—') + ' · Status: ' + escapeHtml(it.status || '—') + '' +
+ '';
+ }).join('');
+ slot.innerHTML =
+ '';
+ }
+
+ function renderTransparency(data, slot) {
+ const sectionsHtml = (data.sections || []).map(function (s) {
+ return '' + escapeHtml(s.heading) + '
' + escapeHtml(s.body).replace(/\n/g, '
') + '
';
+ }).join('');
+ slot.innerHTML = '' + sectionsHtml + '';
+ }
+
+ function renderFria(data, slot) {
+ const headerHtml =
+ '' +
+ '
Rettighet
' +
+ '
Impact (0-5)
' +
+ '
Tiltak
' +
+ '
';
+ const rowsHtml = (data.rights || []).map(function (r) {
+ return '' +
+ '
' + escapeHtml(r.name) + '
' +
+ '
' + r.impact + '
' +
+ '
' + escapeHtml(r.mitigation) + '
' +
+ '
';
+ }).join('');
+ slot.innerHTML = '' + headerHtml + rowsHtml + '
';
+ }
+
+ function renderConformity(data, slot) {
+ const stateOf = function (status) {
+ const s = (status || '').toLowerCase();
+ if (s === 'passed' || s === 'met' || s === 'done') return 'passed';
+ if (s === 'active' || s === 'partial' || s === 'in-progress') return 'active';
+ return 'upcoming';
+ };
+ const dlList = data.deadlines || [];
+ let timelineHtml = '';
+ if (dlList.length) {
+ const milestones = dlList.map(function (d, i) {
+ const left = ((i + 1) / (dlList.length + 1)) * 100;
+ return '' +
+ '
' +
+ '
' +
+ '' + escapeHtml(d.date) + '' +
+ '' + escapeHtml(d.milestone) + '' +
+ '
' +
+ '
';
+ }).join('');
+ timelineHtml =
+ '' +
+ '
' +
+ '
' +
+ milestones +
+ '
' +
+ '
';
+ }
+ const sevForStatus = function (status) {
+ const s = (status || '').toLowerCase();
+ if (s === 'met') return 'low';
+ if (s === 'partial') return 'medium';
+ if (s === 'missing') return 'critical';
+ return 'info';
+ };
+ const items = (data.checklist || []).map(function (it, idx) {
+ return '' +
+ '' +
+ 'C-' + String(idx + 1).padStart(2, '0') + '' +
+ '' + escapeHtml(it.requirement) + '' +
+ 'Bevis: ' + escapeHtml(it.evidence || '—') + ' · ' + escapeHtml(it.status || '—') + '' +
+ '';
+ }).join('');
+ const findingsHtml =
+ '';
+ slot.innerHTML = timelineHtml + findingsHtml;
+ }
+
+ function renderDpia(data, slot) {
+ slot.innerHTML = renderMatrixHtml(data, 5) + renderThreatsTable(data.threats);
+ }
+
+ // ---- Sub-batch B: Security (3) ----
+
+ function renderSecurity(data, slot) {
+ const matrixHtml = renderMatrixHtml(data, 6);
+ const radarHtml = renderRadarSvg(data.dimensions || []);
+ const findingsHtml = renderFindingsBlock(data.findings || [], 'Sikkerhetsfunn');
+ slot.innerHTML = matrixHtml + radarHtml + findingsHtml;
+ }
+
+ function renderRos(data, slot) {
+ const matrixHtml = renderMatrixHtml(data, 5);
+ const radarHtml = renderRadarSvg(data.radar_axes || []);
+ slot.innerHTML = matrixHtml + radarHtml + renderThreatsTable(data.threats);
+ }
+
+ function renderReview(data, slot) {
+ slot.innerHTML = renderFindingsBlock(data.findings || [], 'Funn');
+ }
+
+ // ---- Sub-batch C: Economy (2) ----
+
+ function renderCost(data, slot) {
+ const p10 = data.p10 ? data.p10.monthly : 0;
+ const p50 = data.p50 ? data.p50.monthly : 0;
+ const p90 = data.p90 ? data.p90.monthly : 0;
+ const max = Math.max(p10, p50, p90, 1);
+ const distRows = [
+ { label: 'P10 (lavt)', value: p10 },
+ { label: 'P50 (median)', value: p50 },
+ { label: 'P90 (høyt)', value: p90 }
+ ].map(function (r) {
+ const w = (r.value / max) * 100;
+ return '' +
+ '
' + escapeHtml(r.label) + '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' + r.value.toLocaleString('nb-NO') + ' NOK' +
+ '
' +
+ '
' +
+ '
';
+ }).join('');
+ const distHtml =
+ '' + distRows +
+ '
' +
+ '0' + Math.floor(max / 2).toLocaleString('nb-NO') + '' + max.toLocaleString('nb-NO') + ' NOK/mnd' +
+ '
' +
+ '
';
+ const breakdownRows = (data.monthly_breakdown || []).map(function (m) {
+ return '| ' + escapeHtml(m.component) + ' | ' + m.cost.toLocaleString('nb-NO') + ' NOK |
';
+ }).join('');
+ const breakdownHtml = breakdownRows
+ ? '| Komponent | NOK/mnd |
' + breakdownRows + '
'
+ : '';
+ const tcoHeaders = data.tco_headers || [];
+ const tcoHeader = tcoHeaders.map(function (h) { return '' + escapeHtml(h) + ' | '; }).join('');
+ const tcoRows = (data.tco_table || []).map(function (r) {
+ const cells = tcoHeaders.map(function (h) { return '' + escapeHtml(r[h] || '') + ' | '; }).join('');
+ return '' + cells + '
';
+ }).join('');
+ const tcoHtml = tcoRows
+ ? '' + tcoHeader + '
' + tcoRows + '
'
+ : '';
+ slot.innerHTML = distHtml + breakdownHtml + tcoHtml;
+ }
+
+ function renderLicense(data, slot) {
+ const licenses = data.licenses || [];
+ if (!licenses.length) { slot.innerHTML = renderEmptyState(); return; }
+ const headHtml =
+ '' +
+ '
Kapabilitet
' +
+ licenses.map(function (l) {
+ return '
' + escapeHtml(l.name) + '
';
+ }).join('') +
+ '
';
+ const capabilityNames = (licenses[0].capabilities || []).map(function (c) { return c.name; });
+ const rowsHtml = capabilityNames.map(function (capName, capIdx) {
+ const cells = licenses.map(function (l) {
+ const cap = l.capabilities[capIdx];
+ const status = (cap && cap.status) || 'missing';
+ return '';
+ }).join('');
+ return '' +
+ '
' + escapeHtml(capName) + '
' +
+ cells +
+ '
';
+ }).join('');
+ slot.innerHTML = '' +
+ headHtml + rowsHtml + '
';
+ }
+
+ // ---- Sub-batch D: Documentation (6) ----
+
+ function renderMigrate(data, slot) {
+ const phases = data.phases || [];
+ if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
+ const milestones = phases.map(function (p, i) {
+ const left = ((i + 1) / (phases.length + 1)) * 100;
+ return '' +
+ '
' +
+ '
' +
+ '' + (p.duration_weeks ? p.duration_weeks + ' uker' : '') + '' +
+ '' + escapeHtml(p.name) + '' +
+ '
' +
+ '
';
+ }).join('');
+ const timelineHtml =
+ '' +
+ '
' +
+ '
' +
+ milestones +
+ '
' +
+ '
';
+ const detailsHtml = phases.map(function (p) {
+ const ms = (p.milestones || []).map(function (m) { return '' + escapeHtml(m) + ''; }).join('');
+ const sc = (p.success_criteria || []).map(function (s) { return '' + escapeHtml(s) + ''; }).join('');
+ return '' +
+ '' + escapeHtml(p.name) + ' (' + (p.duration_weeks || '?') + ' uker)
' +
+ (ms ? 'Milepæler
' : '') +
+ (sc ? 'Suksesskriterier
' : '') +
+ '';
+ }).join('');
+ const risksRows = (data.risks || []).map(function (r) {
+ return '| ' + escapeHtml(r.risk || '') + ' | ' + escapeHtml(r.probability || '') + ' | ' + escapeHtml(r.consequence || '') + ' | ' + escapeHtml(r.mitigation || '') + ' |
';
+ }).join('');
+ const risksHtml = risksRows
+ ? '| Risiko | Sannsynlighet | Konsekvens | Tiltak |
' + risksRows + '
'
+ : '';
+ slot.innerHTML = timelineHtml + detailsHtml + risksHtml;
+ }
+
+ function renderAdr(data, slot) {
+ const meta =
+ '' +
+ (data.status ? '- Status
- ' + escapeHtml(data.status) + '
' : '') +
+ (data.date ? '- Date
- ' + escapeHtml(data.date) + '
' : '') +
+ (data.deciders ? '- Deciders
- ' + escapeHtml(data.deciders) + '
' : '') +
+ '
';
+ const sectionsHtml = (data.sections || []).map(function (s) {
+ return '' + escapeHtml(s.heading) + '
' + escapeHtml(s.body).replace(/\n/g, '
') + '
';
+ }).join('');
+ slot.innerHTML =
+ '' +
+ '' + escapeHtml(data.title || 'ADR') + '
' +
+ meta +
+ sectionsHtml +
+ '';
+ }
+
+ function renderSummary(data, slot) {
+ const verdictMap = {
+ block: { variant: 'block', label: 'BLOCK' },
+ warning: { variant: 'warning', label: 'WARNING' },
+ allow: { variant: 'allow', label: 'ALLOW' }
+ };
+ const v = verdictMap[(data.verdict || '').toLowerCase()] || { variant: 'warning', label: (data.verdict || '?').toUpperCase() };
+ const score = v.variant === 'block' ? 92 : v.variant === 'warning' ? 55 : 22;
+ const verdictHtml =
+ '' +
+ '
' +
+ '
' + escapeHtml(v.label) + '
' +
+ '
' + escapeHtml(data.sub || 'AI-vurdering') + '
' +
+ '
' +
+ '
' +
+ '
' +
+ '' + score + '' +
+ 'heuristisk score (0-100)' +
+ '
' +
+ '
' +
+ '
' +
+ 'AllowNoticeWarningBlockCritical' +
+ '
' +
+ '
' +
+ '
';
+ const rationaleHtml = data.rationale
+ ? 'Rationale
' + escapeHtml(data.rationale).replace(/\n/g, '
') + '
'
+ : '';
+ let metricsHtml = '';
+ if ((data.key_metrics || []).length) {
+ const headers = data.metrics_headers || Object.keys(data.key_metrics[0] || {});
+ const headerRow = headers.map(function (h) { return '' + escapeHtml(h) + ' | '; }).join('');
+ const rows = data.key_metrics.map(function (m) {
+ const cells = headers.map(function (h) { return '' + escapeHtml(m[h] || '') + ' | '; }).join('');
+ return '' + cells + '
';
+ }).join('');
+ metricsHtml = 'Key Metrics
' + headerRow + '
' + rows + '
';
+ }
+ const nextHtml = (data.next_steps || []).length
+ ? 'Next Steps
' + data.next_steps.map(function (s) { return '- ' + escapeHtml(s) + '
'; }).join('') + '
'
+ : '';
+ slot.innerHTML = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
+ }
+
+ function renderPoc(data, slot) {
+ const phases = data.phases || [];
+ if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
+ const stagesHtml = phases.map(function (p, i) {
+ const num = String(i + 1).padStart(2, '0');
+ const isCurrent = (i === 0);
+ return '' +
+ '
' + num + '
' +
+ '
' + escapeHtml(p.name) + '
' +
+ '
' + (p.duration_weeks || '?') + ' uker
' +
+ '
';
+ }).join('');
+ const cockpitHtml = '' + stagesHtml + '
';
+ const detailsHtml = phases.map(function (p) {
+ const ms = (p.milestones || []).map(function (m) { return '' + escapeHtml(m) + ''; }).join('');
+ const sc = (p.success_criteria || []).map(function (s) { return '' + escapeHtml(s) + ''; }).join('');
+ return '' +
+ '' + escapeHtml(p.name) + ' (' + (p.duration_weeks || '?') + ' uker)
' +
+ (ms ? 'Milepæler
' : '') +
+ (sc ? 'Suksesskriterier
' : '') +
+ '';
+ }).join('');
+ const risksRows = (data.risks || []).map(function (r) {
+ return '| ' + escapeHtml(r.risk || '') + ' | ' + escapeHtml(r.probability || '') + ' | ' + escapeHtml(r.consequence || '') + ' | ' + escapeHtml(r.mitigation || '') + ' |
';
+ }).join('');
+ const risksHtml = risksRows
+ ? '| Risiko | Sannsynlighet | Konsekvens | Tiltak |
' + risksRows + '
'
+ : '';
+ slot.innerHTML = cockpitHtml + detailsHtml + risksHtml;
+ }
+
+ function renderUtredning(data, slot) {
+ const tocHtml = (data.sections || []).map(function (s, i) {
+ return '' + escapeHtml(s.heading) + '';
+ }).join('');
+ const sectionsHtml = (data.sections || []).map(function (s, i) {
+ return '' + escapeHtml(s.heading) + '
' + escapeHtml(s.body).replace(/\n/g, '
') + '
';
+ }).join('');
+ slot.innerHTML =
+ '' +
+ '
' +
+ '
' +
+ '' + escapeHtml(data.title || 'Utredning') + '
' +
+ sectionsHtml +
+ '' +
+ '
';
+ }
+
+ function renderCompare(data, slot) {
+ const subjects = (data.subjects && data.subjects.length === 2) ? data.subjects : ['Subjekt 1', 'Subjekt 2'];
+ const firstWord = function (s) { return (s || '').toLowerCase().split(/\s+/)[0] || ''; };
+ const fw1 = firstWord(subjects[0]);
+ const fw2 = firstWord(subjects[1]);
+ let count1 = 0, count2 = 0, lik = 0;
+ (data.rows || []).forEach(function (r) {
+ const w = (r.winner || '').toLowerCase();
+ if (!w || /lik|begge|—|-/.test(w)) lik++;
+ else if (fw1 && w.indexOf(fw1) >= 0) count1++;
+ else if (fw2 && w.indexOf(fw2) >= 0) count2++;
+ else lik++;
+ });
+ const summaryHtml =
+ '' +
+ '
' + count1 + ' ' + escapeHtml(subjects[0]) + '
' +
+ '
' + count2 + ' ' + escapeHtml(subjects[1]) + '
' +
+ '
' + lik + ' Lik
' +
+ '
';
+ const headerHtml =
+ '' +
+ '
' + escapeHtml(subjects[0]) + '
' +
+ '
' + escapeHtml(subjects[1]) + '
' +
+ '
';
+ const rowsHtml = (data.rows || []).map(function (r) {
+ const w = (r.winner || '').toLowerCase();
+ let cls1 = 'diff__cell--unchanged', cls2 = 'diff__cell--unchanged';
+ if (fw1 && w.indexOf(fw1) >= 0) cls1 = 'diff__cell--added';
+ if (fw2 && w.indexOf(fw2) >= 0) cls2 = 'diff__cell--added';
+ return '' +
+ '
' + escapeHtml(r.aspect) + ': ' + escapeHtml(r.value1) + '
' +
+ '
' + escapeHtml(r.aspect) + ': ' + escapeHtml(r.value2) + '
' +
+ '
';
+ }).join('');
+ slot.innerHTML = '' + summaryHtml + headerHtml + rowsHtml + '
';
+ }
+
+ // ---- RENDERERS routing-objekt (17 commands) ----
+
+ const RENDERERS = {
+ renderAiActPyramid: renderAiActPyramid,
+ renderRequirements: renderRequirements,
+ renderTransparency: renderTransparency,
+ renderFria: renderFria,
+ renderConformity: renderConformity,
+ renderDpia: renderDpia,
+ renderSecurity: renderSecurity,
+ renderRos: renderRos,
+ renderReview: renderReview,
+ renderCost: renderCost,
+ renderLicense: renderLicense,
+ renderMigrate: renderMigrate,
+ renderAdr: renderAdr,
+ renderSummary: renderSummary,
+ renderPoc: renderPoc,
+ renderUtredning: renderUtredning,
+ renderCompare: renderCompare
+ };
+ window.__RENDERERS = RENDERERS;
+
+ // ---- Paste-import: parser + renderer routing (replaces stub) ----
+
+ function handlePasteImport(commandId, markdown) {
+ const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
+ const slot = document.querySelector('[data-report-slot="' + commandId + '"]');
+ if (!cmd || !cmd.produces_report) {
+ if (slot) slot.innerHTML = renderEmptyState();
+ return;
+ }
+ if (!slot) return;
+ const parser = PARSERS[cmd.report_archetype];
+ const renderer = RENDERERS[cmd.renderer];
+ if (!parser || !renderer) {
+ slot.innerHTML = 'Routing-feil
Mangler parser eller renderer for ' + escapeHtml(cmd.id) + '.
';
+ return;
+ }
+ const result = parser(markdown);
+ slot.innerHTML = '';
+ if (result.ok) renderer(result.data, slot);
+ else renderError(result.errors, slot);
+ }
+ window.__handlePasteImport = handlePasteImport;
+
+ // ============================================================
+ // ONBOARDING SURFACE (Step 5)
+ // ============================================================
+ //
+ // 18 felles felter strukturert i 5 grupper per agents/onboarding-agent.md
+ // Phase 1-5. Sidebar = .form-progress med count utfylte felter per gruppe.
+ // Hver gruppe = .expansion (Tier 3 supplement). Validering bruker
+ // .error-summary (Tier 3) med klikkbare links som fokuserer feil-felt.
+ //
+ // Lagring: commitOnboarding() muterer state.shared..;
+ // Proxy-set-trap dispatcher 'change' → throttled writer persisterer
+ // til IDB. Re-onboard er bare navigate('onboarding') igjen — skjemaet
+ // pre-fylles automatisk fra eksisterende state.
+
+ const ONBOARDING_SCHEMA = [
+ {
+ id: 'organization',
+ title: 'Virksomhetsprofil',
+ sub: 'Hvem er dere?',
+ fields: [
+ { id: 'name', label: 'Virksomhetsnavn', type: 'text', required: true },
+ { id: 'description', label: 'Beskrivelse', type: 'textarea' },
+ { id: 'sector', label: 'Sektor', type: 'select', required: true,
+ options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
+ { id: 'size', label: 'Antall ansatte', type: 'select',
+ options: ['<100', '100-500', '500-2000', '2000-10000', '>10000'] },
+ { id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'multiSelect',
+ options: ['Personopplysningsloven/GDPR', 'Sikkerhetsloven', 'Arkivloven', 'Forvaltningsloven', 'Offentleglova', 'Helseregisterloven', 'Annet'] }
+ ]
+ },
+ {
+ id: 'technology',
+ title: 'Teknologistack',
+ sub: 'Hva har dere allerede?',
+ fields: [
+ { id: 'cloud_platform', label: 'Skyplattform', type: 'multiSelect',
+ options: ['Azure', 'M365', 'Power Platform', 'On-prem', 'Hybrid', 'Annet'] },
+ { id: 'license_type', label: 'Lisenstype', type: 'select',
+ options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Annet'] },
+ { id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'multiSelect',
+ options: ['Azure OpenAI', 'Copilot for M365', 'Copilot Studio', 'AI Builder', 'Azure AI Search', 'Azure AI Services', 'Ingen', 'Annet'] }
+ ]
+ },
+ {
+ id: 'security',
+ title: 'Sikkerhet og compliance',
+ sub: 'Hvilke krav styrer dere etter?',
+ fields: [
+ { id: 'data_classification', label: 'Dataklassifisering', type: 'multiSelect',
+ options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig', 'Hemmelig'] },
+ { id: 'data_residency', label: 'Dataresidens-krav', type: 'select',
+ options: ['Norge', 'Norden', 'EU/EØS', 'Ingen spesifikke krav'] },
+ { id: 'dpia_practice', label: 'DPIA-praksis', type: 'select',
+ options: ['Systematisk', 'Ad hoc', 'Ikke etablert', 'Usikker'] },
+ { id: 'certifications', label: 'Sertifiseringer/rammeverk', type: 'textarea' }
+ ]
+ },
+ {
+ id: 'architecture',
+ title: 'Arkitekturbeslutninger',
+ sub: 'Hvor vil dere?',
+ fields: [
+ { id: 'preferred_platform', label: 'Foretrukket AI-plattform', type: 'select',
+ options: ['Azure AI Foundry', 'Copilot Studio', 'Power Platform/AI Builder', 'Semantic Kernel', 'Ikke bestemt'] },
+ { id: 'integration_needs', label: 'Integrasjonsbehov', type: 'multiSelect',
+ options: ['M365', 'SharePoint', 'Dynamics 365', 'SAP', 'Fagsystemer', 'REST API-er', 'Annet'] },
+ { id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'select',
+ options: ['<500k', '500k-2M', '2M-10M', '>10M', 'Ikke definert'] }
+ ]
+ },
+ {
+ id: 'business',
+ title: 'Forretningsreferanser',
+ sub: 'Hvordan styrer dere?',
+ fields: [
+ { id: 'governance_model', label: 'Styringsmodell for AI', type: 'select',
+ options: ['Sentralisert', 'Desentralisert', 'Hybrid', 'Ikke etablert'] },
+ { id: 'doc_format_preferences', label: 'Dokumentformat', type: 'multiSelect',
+ options: ['Markdown', 'Word', 'PDF', 'Confluence', 'SharePoint Wiki', 'Annet'] },
+ { id: 'reference_architecture', label: 'Referansearkitektur', type: 'textarea' }
+ ]
+ }
+ ];
+
+ function fieldFilled(value, type) {
+ if (value == null) return false;
+ if (type === 'multiSelect') return Array.isArray(value) && value.length > 0;
+ if (type === 'boolean') return value === true;
+ return String(value).trim() !== '';
+ }
+
+ function getOnboardingValue(groupId, fieldId) {
+ const grp = store.state.shared && store.state.shared[groupId];
+ if (!grp) return undefined;
+ return grp[fieldId];
+ }
+
+ function groupProgress(group) {
+ let filled = 0;
+ for (let i = 0; i < group.fields.length; i++) {
+ const f = group.fields[i];
+ if (fieldFilled(getOnboardingValue(group.id, f.id), f.type)) filled++;
+ }
+ return { filled: filled, total: group.fields.length };
+ }
+
+ function renderOnboardingField(field, fieldId, groupId, value) {
+ const path = groupId + '.' + field.id;
+ const dataAttrs = 'data-onboarding-field="' + escapeAttr(path) + '"';
+ const requiredMark = field.required ? '*' : '';
+ const labelHtml = '';
+ let inputHtml = '';
+ if (field.type === 'text') {
+ inputHtml = '';
+ } else if (field.type === 'textarea') {
+ inputHtml = '';
+ } else if (field.type === 'select') {
+ const opts = [''].concat(field.options.map(function (o) {
+ const sel = (o === value) ? ' selected' : '';
+ return '';
+ })).join('');
+ inputHtml = '';
+ } else if (field.type === 'multiSelect') {
+ const arr = Array.isArray(value) ? value : [];
+ const opts = field.options.map(function (o, i) {
+ const checked = arr.indexOf(o) >= 0 ? ' checked' : '';
+ const cbId = fieldId + '-' + i;
+ return '';
+ }).join('');
+ inputHtml = '';
+ } else if (field.type === 'boolean') {
+ const checked = value === true ? ' checked' : '';
+ inputHtml = '';
+ }
+ return '' + labelHtml + inputHtml + '
';
+ }
+
+ function renderOnboardingSurface() {
+ const root = getSurfaceEl('onboarding');
+ if (!root) return;
+
+ const progress = ONBOARDING_SCHEMA.map(function (g) {
+ const p = groupProgress(g);
+ return { id: g.id, title: g.title, filled: p.filled, total: p.total };
+ });
+ const totalFilled = progress.reduce(function (a, p) { return a + p.filled; }, 0);
+ const totalAll = ONBOARDING_SCHEMA.reduce(function (a, g) { return a + g.fields.length; }, 0);
+
+ const sidebarSteps = progress.map(function (p, idx) {
+ let state = 'pending';
+ if (p.filled === p.total) state = 'done';
+ else if (p.filled > 0) state = 'in-progress';
+ const pct = p.total ? Math.round(100 * p.filled / p.total) : 0;
+ const numHtml = (state === 'done' ? '✓' : String(idx + 1));
+ return (
+ ''
+ );
+ }).join('');
+
+ const sidebar = (
+ ''
+ );
+
+ const groupsHtml = ONBOARDING_SCHEMA.map(function (g) {
+ const p = groupProgress(g);
+ const expandedAttr = (p.filled < p.total ? 'true' : 'false');
+ const fieldsHtml = g.fields.map(function (f) {
+ const fieldId = 'ob-' + g.id + '-' + f.id;
+ const value = getOnboardingValue(g.id, f.id);
+ return renderOnboardingField(f, fieldId, g.id, value);
+ }).join('');
+ return (
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
' + fieldsHtml + '
' +
+ '
' +
+ '
' +
+ ''
+ );
+ }).join('');
+
+ const errorSummary = (
+ '' +
+ '
Noen felter må fylles ut
' +
+ '
' +
+ '
'
+ );
+
+ const orgName = store.state.shared.organization && store.state.shared.organization.name;
+ const skipBackBtn = orgName
+ ? ''
+ : '';
+
+ const actionBar = (
+ '' +
+ '' +
+ skipBackBtn +
+ 'Du kan endre alt senere via Re-onboard.' +
+ '
'
+ );
+
+ root.innerHTML = (
+ '' +
+ '
' +
+ sidebar +
+ '
' +
+ '' +
+ errorSummary +
+ '
' + groupsHtml + '
' +
+ actionBar +
+ '
' +
+ '
' +
+ '
'
+ );
+ }
+
+ function readOnboardingValues() {
+ const values = {};
+ ONBOARDING_SCHEMA.forEach(function (g) { values[g.id] = {}; });
+ const root = getSurfaceEl('onboarding');
+ if (!root) return values;
+ const fields = root.querySelectorAll('[data-onboarding-field]');
+ // Initialiser alle multiSelect-felter til [] så uavkryssede arrays
+ // blir tomme arrays (ikke undefined).
+ ONBOARDING_SCHEMA.forEach(function (g) {
+ g.fields.forEach(function (f) {
+ if (f.type === 'multiSelect') values[g.id][f.id] = [];
+ });
+ });
+ for (let i = 0; i < fields.length; i++) {
+ const el = fields[i];
+ const path = el.dataset.onboardingField;
+ const dot = path.indexOf('.');
+ const groupId = path.slice(0, dot);
+ const fieldId = path.slice(dot + 1);
+ if (el.matches('input[type="checkbox"][data-multi-option]')) {
+ if (el.checked) values[groupId][fieldId].push(el.dataset.multiOption);
+ } else if (el.matches('input[type="checkbox"]')) {
+ values[groupId][fieldId] = el.checked;
+ } else {
+ values[groupId][fieldId] = el.value;
+ }
+ }
+ return values;
+ }
+
+ function validateOnboarding(values) {
+ const errors = [];
+ ONBOARDING_SCHEMA.forEach(function (g) {
+ g.fields.forEach(function (f) {
+ if (!f.required) return;
+ const v = values[g.id][f.id];
+ if (!fieldFilled(v, f.type)) {
+ errors.push({
+ path: g.id + '.' + f.id,
+ label: g.title + ' → ' + f.label,
+ message: 'Påkrevd felt mangler verdi'
+ });
+ }
+ });
+ });
+ return errors;
+ }
+
+ function showOnboardingErrors(errors) {
+ const root = getSurfaceEl('onboarding');
+ if (!root) return;
+ const summary = root.querySelector('[data-onboarding-errors]');
+ const list = root.querySelector('[data-onboarding-error-list]');
+ if (!summary || !list) return;
+ if (errors.length === 0) {
+ summary.hidden = true;
+ list.innerHTML = '';
+ return;
+ }
+ summary.hidden = false;
+ list.innerHTML = errors.map(function (e) {
+ return '' + escapeHtml(e.label) + ' — ' + escapeHtml(e.message) + '';
+ }).join('');
+ summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ summary.focus && summary.focus();
+ }
+
+ function commitOnboarding(values) {
+ // Muter via Proxy så change-events fyres og throttled writer persisterer.
+ ONBOARDING_SCHEMA.forEach(function (g) {
+ if (!store.state.shared[g.id]) store.state.shared[g.id] = {};
+ g.fields.forEach(function (f) {
+ const v = values[g.id][f.id];
+ if (f.type === 'multiSelect') {
+ store.state.shared[g.id][f.id] = Array.isArray(v) ? v.slice() : [];
+ } else {
+ store.state.shared[g.id][f.id] = v;
+ }
+ });
+ });
+ }
+
+ // ============================================================
+ // ACTION ROUTER
+ // ============================================================
+ //
+ // Én delegert click-handler på document. Mapper data-action til
+ // registrerte handlers. Surfaces og modaler kan registrere actions ved
+ // å sette window.__ACTIONS[name] = function(ev, el) { ... }.
+
+ const ACTIONS = {};
+ window.__ACTIONS = ACTIONS;
+
+ document.addEventListener('click', function (ev) {
+ const actionEl = ev.target.closest('[data-action]');
+ if (!actionEl) return;
+ const action = actionEl.dataset.action;
+ const handler = ACTIONS[action];
+ if (handler) handler(ev, actionEl);
+ });
+
+ ACTIONS['onboarding-toggle-group'] = function (ev, el) {
+ const exp = el.closest('.expansion');
+ if (!exp) return;
+ const open = exp.getAttribute('aria-expanded') === 'true';
+ exp.setAttribute('aria-expanded', open ? 'false' : 'true');
+ };
+
+ ACTIONS['onboarding-goto-group'] = function (ev, el) {
+ const groupId = el.dataset.group;
+ const root = getSurfaceEl('onboarding');
+ if (!root) return;
+ const exp = root.querySelector('[data-onboarding-group="' + groupId + '"]');
+ if (exp) {
+ exp.setAttribute('aria-expanded', 'true');
+ exp.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ };
+
+ ACTIONS['onboarding-save'] = function (ev) {
+ const values = readOnboardingValues();
+ const errors = validateOnboarding(values);
+ if (errors.length > 0) {
+ showOnboardingErrors(errors);
+ return;
+ }
+ commitOnboarding(values);
+ navigate('home');
+ };
+
+ ACTIONS['onboarding-cancel'] = function () {
+ navigate('home');
+ };
+
+ ACTIONS['onboarding-focus-error'] = function (ev, el) {
+ ev.preventDefault();
+ const path = el.dataset.errorTarget;
+ const root = getSurfaceEl('onboarding');
+ if (!root || !path) return;
+ const fieldRow = root.querySelector('[data-field-row="' + path + '"]');
+ if (!fieldRow) return;
+ const exp = fieldRow.closest('.expansion');
+ if (exp) exp.setAttribute('aria-expanded', 'true');
+ fieldRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ const input = fieldRow.querySelector('input, select, textarea');
+ if (input) input.focus();
+ };
+
+ // ============================================================
+ // NAV + EXPORT/IMPORT ACTIONS (Step 6)
+ // ============================================================
+
+ ACTIONS['goto-home'] = function () { navigate('home'); };
+ ACTIONS['goto-catalog'] = function () { navigate('catalog'); };
+ ACTIONS['goto-onboarding'] = function () { navigate('onboarding'); };
+
+ // Theme toggle (Step 13). Veksler data-theme på , persisterer i
+ // localStorage('ms-ai-architect-theme'). Tar høyde for begrensning fra
+ // file:// + privatmodus. Re-renderer ikke surfaces — endrer kun attributt
+ // og synkroniserer alle [data-theme-label]-elementer in-place.
+ ACTIONS['toggle-theme'] = function () {
+ const current = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
+ const next = current === 'dark' ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-theme', next);
+ try { localStorage.setItem('ms-ai-architect-theme', next); } catch (e) { /* ignore */ }
+ const labels = document.querySelectorAll('[data-theme-label]');
+ for (let i = 0; i < labels.length; i++) {
+ labels[i].textContent = next === 'dark' ? 'Mørk' : 'Lys';
+ }
+ const buttons = document.querySelectorAll('[data-action="toggle-theme"]');
+ for (let j = 0; j < buttons.length; j++) {
+ buttons[j].setAttribute('aria-label', 'Bytt til ' + (next === 'dark' ? 'lys' : 'mørk') + ' modus');
+ }
+ };
+
+ ACTIONS['open-project'] = function (ev, el) {
+ const id = el.dataset.projectId;
+ if (!id) return;
+ store.state.activeProjectId = id;
+ navigate('project');
+ };
+
+ ACTIONS['new-project'] = function () {
+ mountModal(renderNewProjectModalHtml());
+ };
+
+ ACTIONS['modal-cancel'] = function () { unmountModal(); };
+
+ ACTIONS['create-project'] = function () {
+ const modal = document.querySelector('[data-modal-root]');
+ if (!modal) return;
+ const nameEl = modal.querySelector('[data-new-project-field="name"]');
+ const descEl = modal.querySelector('[data-new-project-field="description"]');
+ const errBox = modal.querySelector('[data-new-project-errors]');
+ const errText = modal.querySelector('[data-new-project-error-text]');
+ const name = nameEl ? String(nameEl.value || '').trim() : '';
+ const description = descEl ? String(descEl.value || '').trim() : '';
+ if (!name) {
+ if (errBox && errText) {
+ errBox.hidden = false;
+ errText.textContent = 'Prosjektnavn er påkrevd.';
+ }
+ if (nameEl) nameEl.focus();
+ return;
+ }
+ const scenarios = Array.from(modal.querySelectorAll('[data-new-project-scenario]'))
+ .filter(function (cb) { return cb.checked; })
+ .map(function (cb) { return cb.value; });
+ createProject({ name: name, description: description, scenarios: scenarios });
+ unmountModal();
+ navigate('project');
+ };
+
+ ACTIONS['delete-project'] = function (ev, el) {
+ const id = el.dataset.projectId;
+ const project = findProject(id);
+ if (!project) return;
+ mountModal(renderDeleteProjectModalHtml(project));
+ };
+
+ ACTIONS['confirm-delete-project'] = function (ev, el) {
+ const id = el.dataset.projectId;
+ if (!id) return;
+ deleteProject(id);
+ unmountModal();
+ navigate('home');
+ };
+
+ ACTIONS['project-tab'] = function (ev, el) {
+ const tab = el.dataset.tab;
+ if (!tab) return;
+ currentProjectTab = tab;
+ // Toggle visning uten full re-render (bevarer textarea-input).
+ const root = getSurfaceEl('project');
+ if (!root) return;
+ const tabs = root.querySelectorAll('.project-tab');
+ tabs.forEach(function (t) {
+ if (t.dataset.tab === tab) t.setAttribute('aria-current', 'true');
+ else t.removeAttribute('aria-current');
+ });
+ const panels = root.querySelectorAll('[data-tab-panel]');
+ panels.forEach(function (p) {
+ p.hidden = (p.dataset.tabPanel !== tab);
+ });
+ };
+
+ ACTIONS['parse'] = function (ev, el) {
+ const commandId = el.dataset.command;
+ if (!commandId) return;
+ // Finn nærmeste paste-import textarea (project-overflate eller modal — Step 9
+ // bruker ikke parse-knapp, men vi holder oss generisk via closest()).
+ const scope = el.closest('[data-modal-root], [data-surface]') || document;
+ const textarea = scope.querySelector('[data-paste-import="' + commandId + '"]');
+ if (!textarea) return;
+ const markdown = textarea.value || '';
+ handlePasteImport(commandId, markdown);
+ };
+
+ // ---- Step 8: copy-command + preview-command ----
+
+ ACTIONS['copy-command'] = function (ev, el) {
+ const commandId = el.dataset.command;
+ const formEl = el.closest('[data-command-form]');
+ if (!commandId || !formEl) return;
+ const data = readCommandFormValues(formEl);
+ const cmdString = buildCommand(commandId, data);
+ // Vis preview alltid — clipboard kan feile på file://-protokoll i noen browsers.
+ showCommandPreview(formEl, cmdString);
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(cmdString).then(function () {
+ flashCopyConfirm(formEl, 'Kopiert til utklippstavle.');
+ }).catch(function (err) {
+ console.warn('[playground v3] clipboard write feilet:', err);
+ flashCopyConfirm(formEl, 'Kunne ikke kopiere — bruk forhåndsvisningen under.');
+ });
+ } else {
+ flashCopyConfirm(formEl, 'Clipboard utilgjengelig — bruk forhåndsvisningen under.');
+ }
+ };
+
+ ACTIONS['preview-command'] = function (ev, el) {
+ const commandId = el.dataset.command;
+ const formEl = el.closest('[data-command-form]');
+ if (!commandId || !formEl) return;
+ const data = readCommandFormValues(formEl);
+ showCommandPreview(formEl, buildCommand(commandId, data));
+ };
+
+ // ---- Step 9: catalog actions ----
+
+ ACTIONS['open-catalog-form'] = function (ev, el) {
+ const commandId = el.dataset.command;
+ if (!commandId) return;
+ const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
+ if (!cmd) return;
+ mountModal(renderCatalogFormModalHtml(cmd));
+ };
+
+ ACTIONS['catalog-toggle-group'] = function (ev, el) {
+ const exp = el.closest('.expansion');
+ if (!exp) return;
+ const open = exp.getAttribute('aria-expanded') === 'true';
+ exp.setAttribute('aria-expanded', open ? 'false' : 'true');
+ };
+
+ // Søk-input: input-event oppdaterer query og re-rendrer kun groups-containeren
+ // (bevarer fokus/cursor i selve søke-feltet — full re-render ville flyttet caret).
+ document.addEventListener('input', function (ev) {
+ if (!ev.target.matches || !ev.target.matches('[data-catalog-search]')) return;
+ catalogSearchQuery = ev.target.value || '';
+ refreshCatalogResults();
+ });
+
+ // Eksponer for Verify-asserts og Step 8/9/12.
+ window.__SCENARIOS = SCENARIOS;
+ window.__createProject = createProject;
+ window.__deleteProject = deleteProject;
+ window.__findProject = findProject;
+ window.__mountModal = mountModal;
+ window.__unmountModal = unmountModal;
+ window.__buildCommand = buildCommand;
+ window.__renderCommandForm = renderCommandForm;
+ window.__readCommandFormValues = readCommandFormValues;
+ window.__resolveSharedPath = resolveSharedPath;
+ window.__renderCatalogSurface = renderCatalogSurface;
+ window.__refreshCatalogResults = refreshCatalogResults;
+
+ ACTIONS['export-state'] = function () {
+ try { exportState(); }
+ catch (err) { console.error('[playground v3] export feilet:', err); alert('Eksport feilet: ' + err.message); }
+ };
+
+ ACTIONS['import-state'] = function (ev, el) {
+ const topbar = el.closest('.topbar');
+ if (!topbar) return;
+ const input = topbar.querySelector('[data-import-input]');
+ if (!input) return;
+ input.value = ''; // tillat samme fil to ganger
+ input.click();
+ };
+
+ // File-input change handler (én gang for hele dokumentet — input genereres
+ // fortløpende via renderTopbar, men endringen bobler).
+ document.addEventListener('change', function (ev) {
+ if (!ev.target.matches || !ev.target.matches('[data-import-input]')) return;
+ const file = ev.target.files && ev.target.files[0];
+ if (!file) return;
+ importState(file)
+ .then(function () {
+ scheduleRender();
+ alert('Import fullført. Nåværende state er erstattet av filens innhold.');
+ })
+ .catch(function (err) {
+ console.error('[playground v3] import feilet:', err);
+ alert('Import feilet: ' + err.message);
+ });
+ });
+
+ // Eksponer for Verify-asserts og Steps 6-9.
+ window.__navigate = navigate;
+ window.__scheduleRender = scheduleRender;
+ window.__ONBOARDING_SCHEMA = ONBOARDING_SCHEMA;
+ window.__readOnboardingValues = readOnboardingValues;
+ window.__validateOnboarding = validateOnboarding;
+ window.__commitOnboarding = commitOnboarding;
+
+ // Auto-bootstrap. Kjør så snart DOM er parsed; vi er på slutten av
+ // så DOM er allerede klar.
+ bootstrap().catch(function (err) {
+ console.error('[playground v3] bootstrap failed:', err);
+ });
+ })();
+
diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html b/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html
deleted file mode 100644
index 4090b21..0000000
--- a/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html
+++ /dev/null
@@ -1,3867 +0,0 @@
-
-
-
-
-
- ms-ai-architect — Playground v3
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/plugins/ms-ai-architect/tests/test-playground-parsers.sh b/plugins/ms-ai-architect/tests/test-playground-parsers.sh
index 770600d..1e464ce 100755
--- a/plugins/ms-ai-architect/tests/test-playground-parsers.sh
+++ b/plugins/ms-ai-architect/tests/test-playground-parsers.sh
@@ -14,7 +14,7 @@
set -euo pipefail
PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-v3.html"
+HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-playground.html"
FIXTURES_DIR="$PLUGIN_ROOT/playground/test-fixtures"
# shellcheck disable=SC1091
diff --git a/plugins/ms-ai-architect/tests/test-playground-v3.sh b/plugins/ms-ai-architect/tests/test-playground-v3.sh
index f9668f4..c32d26b 100755
--- a/plugins/ms-ai-architect/tests/test-playground-v3.sh
+++ b/plugins/ms-ai-architect/tests/test-playground-v3.sh
@@ -9,7 +9,7 @@
set -euo pipefail
PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
-HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-v3.html"
+HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-playground.html"
# Inkluder felles helpers (init_suite, pass, fail, warn, print_summary,
# assert_min_lines, assert_matches_pattern). Disse aktiverer set -euo pipefail —
@@ -28,7 +28,7 @@ if [ ! -f "$HTML_FILE" ]; then
print_summary
exit 1
fi
-pass "HTML-fila eksisterer: playground/ms-ai-architect-v3.html"
+pass "HTML-fila eksisterer: playground/ms-ai-architect-playground.html"
assert_min_lines "$HTML_FILE" 1500 "v3 HTML er >= 1500 linjer"