From 2ad02ed00235c4d68ef935356fb08c84216fc33e Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 3 May 2026 20:16:37 +0200 Subject: [PATCH] feat(ms-ai-architect): replace playground v2 with v3 + docs update Step 17 (Wave 5, final). Closes the v3 playground delivery (5-session run, 17 commits total). Pre-flight tests verified passing before deletion: - bash tests/validate-plugin.sh -> 215/215 PASS - bash tests/run-e2e.sh --playground -> 240/240 PASS (170 + 70) Changes: - DELETE playground/ms-ai-architect-v3.html - MOVE v3 content to playground/ms-ai-architect-playground.html (3867 lines). Replaces the deleted v2 file at the same canonical path so external references continue to resolve. - UPDATE tests/test-playground-v3.sh + tests/test-playground-parsers.sh to point at the renamed canonical file. - UPDATE plugin README.md (## Playground (v3) section): describes the 4-surface decision-builder + report-viewer architecture, persistent state model, 17 report renderers, theme toggle, and the validation matrix. - UPDATE plugin CLAUDE.md: replaces v2 5-step pipeline section with v3 architecture overview. Marks docs/playground-v2-spec.md as historical-only (no longer the contract); points at .claude/projects/2026-05-03-playground -v3-architecture/ for v3 spec. - UPDATE root README.md: marketplace listing for ms-ai-architect now describes v3 architecture (4 surfaces, persistence, 17 renderers, theme, 240-test validation) and references the test command. Verify (post-rename): - ! test -f playground/ms-ai-architect-v3.html: pass - test -f playground/ms-ai-architect-playground.html (>3000 lines): pass - grep -q "v3" in plugin README + plugin CLAUDE.md + root README: pass - bash tests/validate-plugin.sh: exit 0 (215/215) - bash tests/run-e2e.sh --playground: exit 0 (240/240) --- README.md | 9 +- plugins/ms-ai-architect/CLAUDE.md | 40 +- plugins/ms-ai-architect/README.md | 33 +- .../ms-ai-architect-playground.html | 5773 +++++++++++------ .../playground/ms-ai-architect-v3.html | 3867 ----------- .../tests/test-playground-parsers.sh | 2 +- .../tests/test-playground-v3.sh | 4 +- 7 files changed, 3879 insertions(+), 5849 deletions(-) delete mode 100644 plugins/ms-ai-architect/playground/ms-ai-architect-v3.html 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.

-
-
11
Azure AI-tjenester
-
8
Kategorier
-
0
Kapabiliteter
-
5
Pipeline-steg
-
-
- - - -
-
- - - - - - - - - - - - - - - - - - - - -
- -
- - -
-
-

Handlekurv

- -
-
-
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 ( + '' + ); + } + + function renderDeleteProjectModalHtml(project) { + const reportCount = projectReportCount(project); + return ( + '' + ); + } + + // ---- 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 = ( + '
' + + '
' + + '' + + '
' + + '

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 '' + escapeHtml(s ? s.name : sid) + ''; + }).join(''); + const dateChip = 'opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + ''; + const progressChip = '' + reportFilled + '/' + reportTotal + ' rapporter'; + + const headerHtml = ( + '
' + + '
' + + '
' + + '

' + escapeHtml(project.name) + '

' + + (project.description ? '

' + escapeHtml(project.description) + '

' : '') + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + dateChip + progressChip + scenarioChips + '
' + + '
' + ); + + // 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') + + '
' + + '
' + + '

Command-katalog

' + + '

24 commands gruppert i 5 kategorier. Åpne skjema for å bygge en pipeline-streng som kopieres til terminalen og kjøres med Claude Code.

' + + '
' + + '
' + + '' + + '' + 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 + ? ( + '
' + + '' + + '
' + + '

Verktøy

' + + '

Dette er et verktøy. Skjemaet bygger en pipeline-streng — ingen rapport-import.

' + + '
' + + '
' + ) + : ''; + return ( + '' + ); + } + + // ============================================================ + // 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 '
' + + '' + + '

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 = + ''; + } + + 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 '' + rows + '
    IDBeskrivelseSeverityTiltak
    '; + } + + 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 '
    ' + + '
    ' + + '
    ' + + '
    ' + escapeHtml(label) + '' + findings.length + '
    ' + + '
      ' + items + '
    ' + + '
    ' + + '
    ' + + '
    '; + } + + 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 '
    ' + + '' + + '' + + '' + + spokes + labels + + '' + + '' + + '
    '; + } + + // ---- 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 + ? '

    Forpliktelser

      ' + + data.obligations.map(function (o) { return '
    • ' + escapeHtml(o) + '
    • '; }).join('') + + '
    ' + : ''; + const meta = '
    ' + + '
    Rolle
    ' + escapeHtml(data.role || '—') + '
    ' + + (data.reasoning ? '
    Begrunnelse
    ' + escapeHtml(data.reasoning).slice(0, 800) + '
    ' : '') + + '
    '; + 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 = + '
    ' + + '
    ' + + '
    ' + + '
    Krav' + (data.items || []).length + '
    ' + + '
      ' + items + '
    ' + + '
    ' + + '
    ' + + '
    '; + } + + 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 = + '
    ' + + '
    ' + + '
    ' + + '
    Sjekkliste' + (data.checklist || []).length + '
    ' + + '
      ' + items + '
    ' + + '
    ' + + '
    ' + + '
    '; + 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 + ? '' + breakdownRows + '
    KomponentNOK/mnd
    ' + : ''; + 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

      ' + ms + '
    ' : '') + + (sc ? '

    Suksesskriterier

      ' + sc + '
    ' : '') + + '
    '; + }).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 + ? '' + risksRows + '
    RisikoSannsynlighetKonsekvensTiltak
    ' + : ''; + 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

      ' + ms + '
    ' : '') + + (sc ? '

    Suksesskriterier

      ' + sc + '
    ' : '') + + '
    '; + }).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 + ? '' + risksRows + '
    RisikoSannsynlighetKonsekvensTiltak
    ' + : ''; + 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 = '
    ' + escapeHtml(field.label) + '' + opts + '
    '; + } 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 = ( + '' + ); + + 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 + + '
    ' + + '
    ' + + '

    Velkommen til ms-ai-architect

    ' + + '

    Fyll inn 18 felles felter — de gjenbrukes på tvers av alle commands og forhåndsutfyller skjemaer per prosjekt.

    ' + + '
    ' + + 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"