ktg-plugin-marketplace/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
Kjell Tore Guttormsen 7ffaa82207 feat(ms-ai-architect): release v1.11.0 — design-system 100%-adoption + visual upgrade
Sesjon 3 av 3 — leverer Fase 7-9 av v1.11.0-planen.

Fase 7 (Acme-rename på demo-state):
- Rename "Acme AS" → "Acme Kommune" og "Demosystem" → "Acme Kunde-chatbot"
  konsistent på tvers av alle 17 fixtures.
- build-demo-state.mjs: organization.name → "Acme Kommune", projects[0] →
  id "acme-kunde-chatbot" / name "Acme: Kunde-chatbot".
- Re-bygd demo-state-v1-blokk i playground HTML.

Fase 8 (Screenshots-regenerering):
- 24 nye PNG-er under playground/screenshots/v1.11.0/ (12 surfaces × 2 tema,
  retina, fullPage). v1.10.0-mappen beholdt som historisk referanse.
- tests/screenshot/run.mjs: OUT_DIR + kommentarer bumpet til v1.11.0.

Fase 9 (Release: docs + versjonsbump):
- plugin.json 1.10.1 → 1.11.0.
- README.md (plugin): version-badge + Version History + screenshot-gallery refs +
  demo-data refs oppdatert.
- CLAUDE.md (plugin): Playground-overskrift v3/v1.10.0 → v3/v1.11.0,
  Demo system-seksjon v1.10.1 → v1.11.0, screenshot-refs v1.10.0 → v1.11.0,
  "Inline CSS-kandidater" konvertert til "Design-system 100%-adoption" status.
- Root README.md: ms-ai-architect-versjon 1.10.1 → 1.11.0, demo-tekst og
  Playground-tekst regenerert for v1.11.0, "271 PASS combined" → "278 PASS".

Verifisering:
- bash tests/run-e2e.sh --playground → 271/271 PASS (static + parsers).
- bash tests/test-playground-migrations.sh → 7/7 PASS.
- Total: 278/278 PASS, 0 FAIL.

Refs: NEXT-SESSION-PROMPT.local.md (Sesjon 3 av 3, plan
.claude/plans/jeg-skal-pr-ve-effervescent-token.md).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:41:36 +02:00

5547 lines
301 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="nb" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ms-ai-architect — Playground v3</title>
<!-- Theme bootstrap. Må kjøre før stylesheets parses for å unngå
flash-of-wrong-theme (FOUC). Prioritet:
1) lagret valg (localStorage 'ms-ai-architect-theme')
2) OS-preferanse via matchMedia('(prefers-color-scheme: dark)')
3) HTML-attributtets default ('dark')
Setter både data-theme + colorScheme for native form-controls/scrollbars.
Wrappes i try/catch — file:// + privatmodus kan blokkere localStorage. -->
<script>
(function () {
var theme = null;
try {
var saved = localStorage.getItem('ms-ai-architect-theme');
if (saved === 'light' || saved === 'dark') theme = saved;
} catch (e) { /* localStorage utilgjengelig */ }
if (!theme && window.matchMedia) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
})();
</script>
<!-- Vendored design-system. Kilden er shared/playground-design-system/ — synces via
scripts/sync-design-system.mjs ved marketplace-rot. Aldri rediger filer under
playground/vendor/ direkte; endringer går i shared/ + re-sync. -->
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
<!-- App-shell layout. Vendored design-system levner komponent-CSS;
her bor kun side-spesifikk layout-grid (sidebar+main, modals, sub-cards).
Kompakt med vilje — ingen komponent-CSS skal duplikeres her. -->
<style>
main#app { min-height: 100vh; padding: 0; }
/* Hidden-attribute respekt. Vendored .error-summary, .modal-backdrop osv.
setter eksplisitt display, som overstyrer HTMLs default [hidden] {display:none}.
Globalt override slik at hidden-attributt faktisk skjuler elementet. */
[hidden] { display: none !important; }
/* .app-shell + .app-shell--wide hentet fra vendored DS v0.3 (tier3-supplement section 25) */
/* App-header (.app-header*) hentet fra vendored DS (components.css). */
/* Onboarding-layout: sidebar + main */
.onboarding-layout { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-6); align-items: start; }
@media (max-width: 880px) { .onboarding-layout { grid-template-columns: 1fr; } .form-progress { position: static; width: auto; } }
.onboarding-header { margin-bottom: var(--space-5); }
.onboarding-header h1 { font-size: var(--font-size-2xl); margin: 0 0 var(--space-2); }
.onboarding-header p { color: var(--color-text-secondary); margin: 0; max-width: 60ch; }
.onboarding-groups { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-6); }
.onboarding-fields { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-2) 0; }
/* Form-patterns (.field-row, .field-label, .field-help, .multi-select,
.checkbox-row, .required-mark) hentet fra vendored DS v0.3 (tier3-supplement section 21) */
.onboarding-actions { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; flex-wrap: wrap; }
.onboarding-help { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
/* Home + project list */
.home-hero { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-5); }
.home-hero h1 { font-size: var(--font-size-3xl); }
.home-hero p { color: var(--color-text-secondary); }
.home-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: var(--space-6) 0 var(--space-3); }
.home-section-head h2 { font-size: var(--font-size-xl); }
.home-section-head .home-section-meta { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
/* Project surface */
.project-header { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5) 0 var(--space-4); border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); }
.project-header__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.project-header__title { font-size: var(--font-size-2xl); margin: 0; }
.project-header__meta { display: flex; flex-wrap: wrap; gap: var(--space-3); font-size: var(--font-size-sm); color: var(--color-text-secondary); }
.project-header__chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--color-bg-soft); color: var(--color-text-secondary); font-size: var(--font-size-xs); font-family: var(--font-family-mono); }
.project-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); flex-wrap: wrap; }
.project-tab { background: transparent; border: 0; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-secondary); border-bottom: 2px solid transparent; margin-bottom: -1px; }
.project-tab:hover { color: var(--color-text-primary); }
.project-tab[aria-current="true"] { color: var(--color-text-primary); border-bottom-color: var(--color-primary-500); }
.project-tab__count { display: inline-block; margin-left: 6px; padding: 1px 6px; background: var(--color-bg-soft); border-radius: 10px; font-size: 11px; color: var(--color-text-tertiary); }
.command-cards { display: flex; flex-direction: column; gap: var(--space-4); }
/* .card + .card__* hentet fra vendored DS (base.css + tier3-supplement). */
.sub-zone { border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-3); }
.sub-zone__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
.paste-import-row { display: flex; flex-direction: column; gap: var(--space-2); }
.paste-import-row__actions { display: flex; gap: var(--space-2); align-items: center; }
.form-zone-placeholder { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
.report-slot { min-height: 24px; }
.report-slot:empty::before { content: "Ingen importert rapport ennå."; font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; padding: var(--space-4); }
.modal { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-5); max-width: 560px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; gap: var(--space-4); }
.modal--wide { max-width: 760px; }
.modal__title { margin: 0; font-size: var(--font-size-xl); }
.modal__actions { display: flex; gap: var(--space-2); justify-content: flex-end; padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
[data-theme="dark"] .modal-backdrop { background: rgba(0,0,0,0.7); }
/* Command form (Step 8) */
.command-form { display: flex; flex-direction: column; gap: var(--space-3); }
.command-form__fields { display: flex; flex-direction: column; gap: var(--space-3); }
.command-form__actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; padding-top: var(--space-2); border-top: 1px dashed var(--color-border-subtle); }
.command-form__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.command-form__copy-confirm { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
.field-from-tag { display: inline-block; padding: 1px 6px; background: var(--color-bg-soft); color: var(--color-text-tertiary); border-radius: var(--radius-sm); font-size: 10px; font-weight: var(--font-weight-medium); margin-left: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
.form-preview { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); margin-top: var(--space-2); overflow-x: auto; }
.form-preview__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
.code-block { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: pre-wrap; word-break: break-all; margin: 0; }
/* 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; }
/* .card + .card__* hentet fra vendored DS (base.css + tier3-supplement). */
.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); }
/* Foundation .page__*, .key-stats, .key-stat--{level} hentet fra vendored DS (tier3-supplement section 14-15).
.top-risks*, .top-risk*, .recommendation-card* hentet fra DS section 18-19.
.pair-before-after*, .pyramide-tier-detail*, .tab-list/.tab/.tab-panel også fra DS. */
/* .verdict-pill: plugin-domain semantikk (go/block/approved/allow/warning/n-a) — distinkt fra
DS .verdict-pill-lg (severity-band: critical/high/medium/low/positive). Domain-pillen brukes
av architect-rapporter for GO/BLOCK-beslutninger; severity-pillen brukes for risiko-band. */
.verdict-pill { display: inline-flex; align-items: center; padding: var(--space-2) var(--space-4); border-radius: var(--radius-pill); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap; flex-shrink: 0; }
.verdict-pill[data-verdict="go"],
.verdict-pill[data-verdict="approved"],
.verdict-pill[data-verdict="allow"] { background: var(--color-state-success); color: #fff; }
.verdict-pill[data-verdict="go-with-conditions"],
.verdict-pill[data-verdict="warning"] { background: var(--color-severity-medium); color: var(--color-severity-medium-on); }
.verdict-pill[data-verdict="block"],
.verdict-pill[data-verdict="failed"] { background: var(--color-severity-critical); color: var(--color-severity-critical-on); }
.verdict-pill[data-verdict="n-a"] { background: var(--color-bg-soft); color: var(--color-text-secondary); border: 1px solid var(--color-border-subtle); }
/* .scenario-card[data-status="met/partial/missing"]: plugin-spesifikke 3-stadie-status —
DS har kun "winner". Beholdt for AI Act / cost-distribution / capability-matrix-renderers. */
.scenario-card[data-status="met"] { border-left: 4px solid var(--color-state-success); }
.scenario-card[data-status="partial"] { border-left: 4px solid var(--color-severity-medium); }
.scenario-card[data-status="missing"] { border-left: 4px solid var(--color-severity-critical); }
/* AI Act-pyramide-overrides: bumpe label-font så tier-tekst ikke klippes,
sikre tilstrekkelig parent-bredde (DS-default har ingen min-width). */
.pyramide { min-width: 480px; max-width: 100%; }
.pyramide__tier { font-size: var(--font-size-md); padding: var(--space-3) var(--space-4); }
@media (max-width: 560px) { .pyramide { min-width: 0; } .pyramide__tier { font-size: var(--font-size-sm); padding: 8px 12px; } }
/* .read-more-block + .suppressed-panel: native <details>-baserte mønstre — distinkte fra
DS .read-more og .suppressed (som bruker JS-toggled aria-expanded). */
.read-more-block { margin: var(--space-2) 0; }
.read-more-block summary { cursor: pointer; color: var(--color-text-link); font-weight: var(--font-weight-medium); }
.suppressed-panel { margin: var(--space-4) 0 0 0; padding: var(--space-3) var(--space-4); background: var(--color-bg-soft); border: 1px dashed var(--color-border-subtle); border-radius: var(--radius-md); opacity: 0.85; }
.suppressed-panel summary { cursor: pointer; color: var(--color-text-secondary); font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); }
.suppressed-panel[open] summary { margin-bottom: var(--space-2); }
.suppressed-panel__list { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-2) 0 0 0; }
.suppressed-panel__item { padding: var(--space-2) var(--space-3); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-secondary); display: flex; gap: var(--space-3); align-items: baseline; }
.suppressed-panel__id { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
</style>
</head>
<body>
<!-- Walking-skeleton: 4 placeholder-overflater. Step 5-7 fyller dem ut.
Bare én av disse er aktiv om gangen via state.activeSurface. -->
<main id="app">
<section id="surface-onboarding" data-surface="onboarding" hidden></section>
<section id="surface-home" data-surface="home" hidden></section>
<section id="surface-catalog" data-surface="catalog" hidden></section>
<section id="surface-project" data-surface="project" hidden></section>
</main>
<!-- Inlined demo-state for "Last inn demo-data"-knapp. Bygges av
scripts/build-demo-state.mjs fra playground/test-fixtures/*.md.
IKKE rediger manuelt — kjør skriptet på nytt. -->
<script type="application/json" id="demo-state-v1">
{
"schemaVersion": 1,
"dataVersion": 2,
"shared": {
"organization": {
"name": "Acme Kommune",
"description": "Mellomstor norsk kommune med ~8 000 ansatte. Ansvar for skole, helse, byggesak og digitalisering. Bruker pluginen for å vurdere AI-tjenester før innføring.",
"sector": "Kommunal",
"size": "8 000"
},
"regulatory": {
"regulatory_requirements": "GDPR/Personopplysningsloven, Sikkerhetsloven, Forvaltningsloven, Arkivloven, Helseregisterloven (for helsetjenestene)",
"ai_act_role": "deployer",
"risk_level": "high"
},
"technology": {
"cloud_platform": "Azure (Norge Øst), M365 E5, on-prem datasenter for kommunale fagsystem",
"license_type": "M365 E5 (alle ansatte) + Azure Enterprise Agreement + Power Platform per app",
"ai_services_in_use": "Azure OpenAI (GPT-4o), Azure AI Search, Copilot for M365 (pilot 50 brukere), Power Automate AI Builder"
},
"security": {
"data_classification": [
"Åpen",
"Intern",
"Fortrolig"
],
"data_residency": "EU/EØS — fortrinnsvis Norge",
"dpia_practice": "Sentralt personvernombud + kommune-DPO. Mal etter Datatilsynet. DPIA er obligatorisk for alle nye AI-tjenester som behandler personopplysninger.",
"certifications": "ISO 27001, NSM grunnprinsipper for IKT-sikkerhet, Digdir Trygg-pilot"
},
"architecture": {
"preferred_platform": "Azure AI Foundry (for nye løsninger), Copilot Studio (for low-code agenter)",
"integration_needs": "M365, Public 360 (sak/arkiv), KOMTEK (byggesak), Visma Enterprise HRM, REST API mot folkeregister og matrikkel",
"annual_ai_budget": "3 MNOK (2026), forventet 5 MNOK (2027)"
},
"business": {
"governance_model": "Sentralt AI-råd ledes av digitaliseringsdirektør. Beslutninger over 500 kNOK eskalerer til CIO. Tillitsvalgt og personvernombud inkluderes i alle høyrisiko-vurderinger.",
"doc_format_preferences": "Markdown for tekniske dokumenter, PDF for styringsdokumenter, Confluence for arbeidsdokumenter",
"reference_architecture": "TOGAF-tilpasset, Digdir arkitekturprinsipper, intern Confluence /arkitektur"
}
},
"projects": [
{
"id": "acme-kunde-chatbot",
"name": "Acme: Kunde-chatbot",
"description": "AI-chatbot som hjelper innbyggere med byggesak-spørsmål. Trenger DPIA, ROS, EU AI Act-klassifisering og kostnadsestimat før beslutning. Alle 17 rapport-typer er pre-importert med eksempel-data.",
"scenarios": [
"Chatbot/agent",
"Beslutningsstøtte"
],
"createdAt": "2026-05-04T08:00:00.000Z",
"reports": {
"classify": {
"input": {},
"raw_markdown": "# EU AI Act — Klassifisering: Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nBeskrivelse: AI-system som identifiserer objekter som krever oppfølging via sensordata + objektregister\n\n## Risikonivå\n\nRisk-level: høy\n\n## Rolle\n\nRolle: Provider og Deployer (utvikler internt + drifter selv)\n\n## Begrunnelse\n\nReasoning: Systemet brukes av offentlig myndighet for håndheving av lov, og påvirker individers rettigheter direkte gjennom automatisert beslutningsstøtte for håndtering. Dette plasserer systemet under Annex III, punkt 6 (rettshåndhevelse) og krever full høyrisiko-compliance per Art. 6(2).\n\n## Forpliktelser\n\n- Risk management system per Art. 9\n- Data governance og -kvalitet per Art. 10\n- Teknisk dokumentasjon per Art. 11\n- Logging og sporbarhet per Art. 12\n- Transparens overfor deployer per Art. 13\n- Menneskelig oversikt per Art. 14\n- Robusthet, sikkerhet og nøyaktighet per Art. 15\n- FRIA (Fundamental Rights Impact Assessment) per Art. 27 — obligatorisk for offentlig sektor\n- Registrering i EU-database per Art. 49\n- Conformity assessment per Art. 43\n\n## Frist\n\nFull compliance innen 2027-08-02 (Annex III høyrisiko full compliance).\n"
},
"requirements": {
"input": {},
"raw_markdown": "# EU AI Act — Krav for høyrisiko provider+deployer\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nKlassifisering: høy risiko, rolle Provider+Deployer\n\n## Krav\n\n| Krav | Status | Kilde |\n|------|--------|-------|\n| Risk Management System etablert og dokumentert | partial | Art. 9 |\n| Treningsdata-governance med kvalitetssjekker | met | Art. 10 |\n| Teknisk dokumentasjon (Annex IV) komplett | partial | Art. 11 |\n| Automatisk logging av hendelser implementert | met | Art. 12 |\n| Transparens-instruksjoner for deployer skrevet | missing | Art. 13 |\n| Human-in-the-loop på alle sanksjonsavgjørelser | met | Art. 14 |\n| Nøyaktighetsmål med stratifisert testing | partial | Art. 15 |\n| Cybersikkerhetstiltak verifisert (NSM Grunnprinsipper) | met | Art. 15 |\n| FRIA gjennomført før idriftsettelse | missing | Art. 27 |\n| Registrering i EU-database planlagt | missing | Art. 49 |\n| Conformity assessment per Annex VI gjennomført | missing | Art. 43 |\n| CE-merking utført før markedsføring | missing | Art. 48 |\n| Post-market monitoring system etablert | partial | Art. 72 |\n| Avviksrapportering til myndigheter rutinert | partial | Art. 73 |\n\n## Sammendrag\n\n- 4 krav er møtt (met)\n- 4 krav er delvis møtt (partial)\n- 6 krav mangler implementering (missing)\n\nPrioritering: FRIA og transparens-instruksjoner må adresseres før idriftsettelse 2027-08-02.\n"
},
"transparency": {
"input": {},
"raw_markdown": "# Transparensnotis — Acme Kunde-chatbot\n\nTittel: Informasjon om automatisert operasjonell analyse (Art. 13 og Art. 50)\n\n## Hva systemet gjør\n\nAcme Kommune bruker et AI-system som leser av objekt-ID (Acme Kunde-chatbot — automatisert klassifisering) fra sensordata langs produksjonsmiljøet. Systemet identifiserer objekter som har overtrådt terskelverdi gjennom å beregne gjennomsnittlig respons mellom to datapunkt.\n\n## Hvilke data som behandles\n\nBehandlede data inkluderer objekt-ID, tidsstempel, datapunkt, objektklasse og oppslag i Acme Kommune objektregister. Personlig identifiserbar informasjon kobles ikke til oppføring uten saksbehandler eksplisitte godkjenning.\n\n## Hvordan beslutninger tas\n\nSystemet er beslutningsstøtte, ikke -taker. Hver flagged hendelse går til menneskelig saksbehandler som tar endelig avgjørelse om gebyr eller anmeldelse. AI-output inkluderer konfidensgrad og forklaring av hvorfor saken ble flagget.\n\n## Dine rettigheter\n\nSom registrert har du rett til innsyn (GDPR Art. 15), retting (Art. 16), sletting (Art. 17 — med begrensninger ved lovhjemmel), og å klage til Datatilsynet. Du kan også be om manuell vurdering uten AI-bistand per GDPR Art. 22.\n\n## Kontakt\n\nPersonvernombud: pvo@Acme.no\nTilsyn: Datatilsynet — postkasse@datatilsynet.no\nEU AI Act-tilsyn: under etablering (Digitaliseringsdirektoratet er forventet)\n"
},
"frimpact": {
"input": {},
"raw_markdown": "# FRIA (Fundamental Rights Impact Assessment) — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nHjemmel: EU AI Act Art. 27 (obligatorisk for offentlig sektor)\n\n## Vurderte rettigheter\n\n| Rettighet | Impact | Tiltak |\n|-----------|--------|--------|\n| Menneskeverd | 1 | Ingen reduksjon — saksbehandler tar endelig avgjørelse, ikke AI |\n| Rett til frihet og sikkerhet | 1 | Ingen frihetsberøvelse direkte fra AI; politi/domstol er reell beslutter |\n| Respekt for privatliv | 4 | Massiv overvåking via veikameraer — kompenseres med strenge oppbevaringsregler (90 dager), formålsbegrensning, og minimering av kobling til objektregister |\n| Personvern | 4 | DPIA gjennomført; Datatilsynet konsultert; rettslig grunnlag i interne retningslinjer §13 — likevel høy impact pga skala |\n| Ikke-diskriminering | 3 | Algoritmisk bias-testing på objekt-ID fra utenlandske registre (lavere Acme Kunde-chatbot-nøyaktighet) — kvartalsvis review |\n| Ytringsfrihet og informasjonsfrihet | 0 | Ikke berørt |\n| Forsamlingsfrihet | 0 | Ikke berørt |\n| Religionsfrihet | 0 | Ikke berørt |\n| Eiendomsrett | 2 | Gebyr/sanksjoner berører eiendomsrett — kompenseres med klagemulighet og rettslig prøving |\n| Rett til effektivt rettsmiddel | 2 | Klageadgang sikret; menneskelig review garantert; AI-forklaring tilgjengelig for klager |\n| Barns rettigheter | 1 | Lav direkte påvirkning; barn er sjelden registrerte førere |\n| Eldres rettigheter | 2 | Eldre kan ha vanskeligere for å klage digitalt — papir-klage må fortsatt være tilgjengelig |\n\n## Konklusjon\n\nTre rettigheter har høy impact (3-4): privatliv, personvern og ikke-diskriminering. Tiltakene reduserer reell risiko, men FRIA må re-evalueres årlig per Art. 27(2).\n"
},
"conformity": {
"input": {},
"raw_markdown": "# Samsvarsvurdering (Art. 43) — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nVurderingsprosedyre: Annex VI (intern kontroll)\n\n## Sjekkliste\n\n| Krav | Status | Bevis |\n|------|--------|-------|\n| Risk Management System dokumentert | bestått | RMS-rapport v2.1 (2026-04-15) |\n| Treningsdata-governance med kvalitetskriterier | bestått | Data-governance handbook §4.2 |\n| Teknisk dokumentasjon Annex IV komplett | betinget | Mangler ytelsesmål per stratum |\n| Logging av hendelser implementert | bestått | OpenTelemetry-spans i Azure Monitor |\n| Transparens-instruksjoner skrevet | avvist | Skal leveres innen 2026-09-01 |\n| Menneskelig oversikt på saksbehandler | bestått | Workflow-design godkjent av juridisk |\n| Nøyaktighetsmål dokumentert | betinget | 96.3% overall, men ikke per objekt-ID-region |\n| Robusthet under adversarielle forhold | betinget | Test-suite mangler skitne plater og natt-scenarier |\n| Cybersikkerhetstiltak per Art. 15 | bestått | NSM Grunnprinsipper-vurdering bestått |\n| Conformity assessment underskrevet | avvist | Avhengig av FRIA-resultat |\n| EU declaration of conformity utstedt | avvist | Avhenger av Art. 47 |\n| CE-merking påført | avvist | Markedsplassering ikke aktuell (intern bruk) — vurder om Art. 48 gjelder |\n\n## Frister\n\n| Dato | Milepæl | Status |\n|------|---------|--------|\n| 2026-08-02 | GPAI-krav + Annex III høyrisiko | upcoming |\n| 2026-09-01 | Transparens-instruksjoner ferdigstilt | upcoming |\n| 2027-02-01 | FRIA og DPIA-revisjon | upcoming |\n| 2027-08-02 | Full Annex III høyrisiko-compliance | upcoming |\n\n## Konklusjon\n\n5 av 12 krav er fullt møtt; 4 er delvis møtt; 3 mangler implementering. Critical path: transparens-instruksjoner (Art. 13) blokkerer conformity declaration.\n"
},
"dpia": {
"input": {},
"raw_markdown": "# DPIA / PVK — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nMetodikk: Datatilsynets veileder + ISO/IEC 29134\n\n## Risikomatrise (5×5)\n\n| Trussel | Sannsynlighet | Konsekvens | Score | Nivå |\n|---------|---------------|------------|-------|------|\n| Feilaktig objekt-ID-tolkning fører til urettmessig sanksjon | 3 | 4 | 12 | medium |\n| Massiv lokasjonsdata-lekkasje fra objektregister | 2 | 5 | 10 | medium |\n| AI-forklaring viser sensitiv kontekst om eier | 3 | 3 | 9 | medium |\n| Stratifisert bias mot utenlandske objekt-ID | 4 | 3 | 12 | medium |\n| Fysisk angrep på sensordata skaper deteksjonshull | 2 | 2 | 4 | low |\n| Insider-misbruk for sporing av enkeltpersoner | 2 | 5 | 10 | medium |\n| Auto-flagging utløser kjedereaksjon ved system-feil | 1 | 5 | 5 | low |\n| Subject Access Request (GDPR Art. 15) ignoreres | 3 | 3 | 9 | medium |\n\n## Trusler\n\n| ID | Beskrivelse | Severity | Tiltak |\n|----|-------------|----------|--------|\n| T-001 | Feilaktig OCR av objekt-ID | high | Konfidensgrad-cutoff på 0.95; saksbehandler-review under cutoff |\n| T-002 | Lokasjonsdata-lekkasje | critical | Pseudonymisering ved lagring; HSM-backed nøkler i Azure Key Vault |\n| T-003 | Kontekst-eksponering i AI-forklaring | high | Filter på sensitive felt; kontekst kun til autorisert saksbehandler |\n| T-004 | Bias mot utenlandske registre | high | Kvartalsvis stratifisert testing; juster modell ved >5% avvik |\n| T-005 | Insider-misbruk | critical | Audit-logging på alle oppslag; SIEM-deteksjon av unormale mønstre |\n\n## Tiltak\n\n| ID | Tiltak | Status | Eier |\n|----|--------|--------|------|\n| M-001 | Cutoff-konfidensgrad implementert | done | Tech Lead |\n| M-002 | Pseudonymisering pilotert | in-progress | Sikkerhetsarkitekt |\n| M-003 | Bias-test-pipeline etablert | planned | Data Scientist |\n| M-004 | Audit-logging utrullet | done | Drift |\n| M-005 | SIEM-regler kalibrert | in-progress | SOC |\n\n## Konklusjon\n\nRestrisiko: 4×3 → 2×2\n\nRestrisiko etter tiltak: medium-lav. DPIA godkjent av Datatilsynet 2026-04-22.\n"
},
"security": {
"input": {},
"raw_markdown": "# Sikkerhetsvurdering 6×5 — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nRammeverk: NSM Grunnprinsipper + Microsoft Cloud Security + EU AI Act Art. 15\n\n## Score per dimensjon\n\n| Dimensjon | Score | Vurdering |\n|-----------|-------|-----------|\n| Identitet og tilgang | 4 | Entra ID med MFA, conditional access; mangler PIM på enkelte serviceprinciper |\n| Datasikkerhet og personvern | 3 | Customer-managed keys, pseudonymisering pilotert; full Customer Lockbox ikke aktivert |\n| Modell- og prompt-sikkerhet | 3 | Content filters aktivert; jailbreak-deteksjon via Azure AI Content Safety; ingen red-team-runde gjort |\n| Nettverk og perimeter | 5 | Private Endpoint mot alle Azure AI-tjenester; ingen offentlig eksponering |\n| Logging og hendelseshåndtering | 4 | OpenTelemetry → Sentinel; SOC integrert; mangler automatisk avviksdeteksjon for AI-output |\n| Operasjonell og leverandørsikkerhet | 3 | Hovedleverandører verifisert; mangler third-party penetrasjons-test siste 12 mnd |\n\n## Risikomatrise (6×5)\n\n| Risiko | Sannsynlighet | Konsekvens | Score |\n|--------|---------------|------------|-------|\n| Lekkasje av treningsdata | 2 | 5 | 10 |\n| Prompt injection i forklaringsmodell | 3 | 3 | 9 |\n| Modell-tyveri (model extraction) | 2 | 3 | 6 |\n| Adversarielt eksempel forgifter output | 2 | 4 | 8 |\n| Cloud-leverandør-utilgjengelighet | 2 | 4 | 8 |\n| Insider-trussel (unauthorized inference) | 2 | 5 | 10 |\n\n## Funn\n\n| ID | Severity | Lokasjon | Anbefaling |\n|----|----------|----------|------------|\n| S-01 | high | Identity | Aktivér PIM på alle serviceprinciper innen 2026-06-01 |\n| S-02 | medium | Data | Aktivér Customer Lockbox for operasjonelle data |\n| S-03 | high | Model | Gjennomfør formell red-team-runde med Azure AI Red Team-veiledning |\n| S-04 | low | Network | Periodisk verifikasjon av Private Endpoint-konfigurasjon |\n| S-05 | medium | Logging | Implementer ML-basert avviksdeteksjon på AI-output-rate |\n| S-06 | medium | Vendor | Bestilt third-party penetrasjons-test for Q3 2026 |\n\n## Top-risikoer\n\n| ID | Risiko | Score | Severity |\n|----|--------|-------|----------|\n| R-01 | Lekkasje av treningsdata | 10 | high |\n| R-02 | Insider-trussel (unauthorized inference) | 10 | high |\n| R-03 | Prompt injection i forklaringsmodell | 9 | high |\n| R-04 | Adversarielt eksempel forgifter output | 8 | medium |\n| R-05 | Cloud-leverandør-utilgjengelighet | 8 | medium |\n\n## Kategori-snitt\n\n| Kategori | Snitt |\n|----------|-------|\n| Identitet og tilgang | 4 |\n| Datasikkerhet og personvern | 3 |\n| Modell- og prompt-sikkerhet | 3 |\n| Nettverk og perimeter | 5 |\n| Logging og hendelseshåndtering | 4 |\n| Operasjonell og leverandørsikkerhet | 3 |\n\nRestrisiko: 5×4 → 2×3\n\n## Aggregat\n\nTotalscore: 22/30 (73%) — modent men ikke best-i-klassen. Modell- og prompt-sikkerhet er svakeste dimensjon.\n"
},
"ros": {
"input": {},
"raw_markdown": "# ROS-analyse — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nMetodikk: NS 5814 / ISO 31000 + AI-trusselbibliotek\n\n## Risikomatrise (5×5)\n\n| Trussel | Sannsynlighet | Konsekvens | Score | Nivå |\n|---------|---------------|------------|-------|------|\n| Modell-drift som degraderer nøyaktighet | 4 | 3 | 12 | medium |\n| Treningsdata-bias mot småbiler eller MC | 3 | 3 | 9 | medium |\n| Adversarielle plate-design unngår OCR | 2 | 4 | 8 | medium |\n| API-utilgjengelighet i kritisk periode | 2 | 4 | 8 | medium |\n| Klage-saksbehandling overbelastet ved skalering | 4 | 3 | 12 | medium |\n| Datatap pga manglende georedundans | 1 | 5 | 5 | low |\n| Misbruk av AI-forklaring som bevis | 3 | 4 | 12 | medium |\n| Kjedevirkning ved feil i objektregister | 2 | 5 | 10 | medium |\n\n## Radar-akser (7 dimensjoner)\n\n| Akse | Score (1-5) |\n|------|-------------|\n| Tilgjengelighet | 4 |\n| Konfidensialitet | 4 |\n| Integritet | 4 |\n| Sporbarhet | 5 |\n| Pålitelighet | 3 |\n| Robusthet | 3 |\n| Etterlevelse | 4 |\n\n## Trusler\n\n| ID | Beskrivelse | Severity | Tiltak |\n|----|-------------|----------|--------|\n| T-101 | Modell-drift over tid | high | Månedlig retraining-pipeline; alarm ved >2% nøyaktighetsfall |\n| T-102 | Bias mot småbiler/MC | high | Stratifisert evaluering ved hver release |\n| T-103 | Adversarielle plate-design | medium | Robusthetstest mot kjente angreps-mønstre |\n| T-104 | API-utilgjengelighet | medium | Multi-region failover med RTO 1t |\n| T-105 | Saksbehandlings-overbelastning | high | Automatisk batching + prioriteringsregler |\n\n## Tiltak\n\n| ID | Tiltak | Status | Eier |\n|----|--------|--------|------|\n| M-101 | Retraining-pipeline etablert | done | MLOps |\n| M-102 | Stratifisert evalueringssett bygget | in-progress | Data Scientist |\n| M-103 | Robusthetstest planlagt | planned | Sikkerhetsarkitekt |\n| M-104 | Multi-region failover testet | done | Drift |\n| M-105 | Batching-logikk implementert | in-progress | Tech Lead |\n\n## Top-risikoer\n\n| ID | Trussel | Score | Severity |\n|----|---------|-------|----------|\n| T-101 | Modell-drift over tid | 12 | high |\n| T-105 | Saksbehandlings-overbelastning | 12 | high |\n| T-107 | Misbruk av AI-forklaring som bevis | 12 | high |\n| T-108 | Kjedevirkning ved feil i objektregister | 10 | high |\n| T-103 | Bias mot småbiler/MC | 9 | medium |\n\nRestrisiko: 4×3 → 2×2\n\n## Anbefaling\n\nROS godkjent av seksjonsleder 2026-04-25 forutsatt at M-103 (robusthetstest) ferdigstilles innen 2026-06-15. Re-evaluering ved hver modell-release eller ved endring i sak-volum > 20%.\n\n## Konklusjon\n\nRestrisiko etter tiltak: medium. ROS godkjent av seksjonsleder 2026-04-25.\n"
},
"review": {
"input": {},
"raw_markdown": "# Arkitekturgjennomgang — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nVurderingsdato: 2026-04-30\nReviewers: AI-arkitekt, sikkerhetsarkitekt, Datatilsynet\n\n## Funn\n\n| ID | Severity | Status | Lokasjon | Anbefaling |\n|----|----------|--------|----------|------------|\n| F-01 | critical | remove | Authentication layer | Tilgang til AI-forklaringer mangler attribute-based access control — alle saksbehandler ser alle saker. Implementer ABAC basert på sak-tildeling. |\n| F-02 | high | review | Data pipeline | Treningsdata oppdateres månedlig, men ingen formell drift-deteksjon. Etabler statistisk drift-monitoring i Azure Monitor. |\n| F-03 | high | review | Model serving | Modellen serves fra en enkelt regional endpoint uten failover. Replikér til en sekundær region for RTO < 1t. |\n| F-04 | high | review | Logging | Audit-logg lagres 30 dager — under arkivlovens krav for sak-relevant info. Endre retensjon til 7 år for sak-knyttede oppslag. |\n| F-05 | medium | keep | Cost management | Ingen budsjettalarmer på Azure AI Services — prediction-kostnaden kan øke med 4× ved belastnings-topper uten varsel. |\n| F-06 | medium | review | Compliance | FRIA-rapport ikke vedlikeholdt etter modell-endring 2026-03-12. Re-evaluering trengs. |\n| F-07 | medium | keep | UX | saksbehandler-grensesnitt viser ikke konfidensgrad tydelig nok — risiko for over-trust på AI-output. |\n| F-08 | low | suppressed | Documentation | README mangler oppdatert arkitekturdiagram (siste fra 2025-11). |\n| F-09 | low | suppressed | Testing | Manglende E2E-test for utenlandske objekt-ID. |\n\n## Sammendrag\n\nCritical (1): ABAC mangler — må fikses før idriftsettelse.\nHigh (3): Drift-deteksjon, failover, logg-retensjon — må fikses innen 6 mnd.\nMedium (3): Budsjett, FRIA-revisjon, UX-konfidens — bør fikses innen 12 mnd.\nLow (2): Dokumentasjon, testing — opportunity-quality.\n\n## Anbefaling\n\nIdriftsettelse anbefales IKKE før F-01 er løst. F-02 til F-04 må adresseres innen 2026-09-01 for å holde 2027-08-02-fristen.\n"
},
"cost": {
"input": {},
"raw_markdown": "# Kostnadsestimat — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nPeriode: 12 måneder fra produksjonssetting\nValuta: NOK\n\n## Distribusjon (P10/P50/P90)\n\n| Persentil | Månedlig (NOK) | Årlig (NOK) |\n|-----------|----------------|-------------|\n| P10 | 78 000 | 936 000 |\n| P50 | 142 000 | 1 704 000 |\n| P90 | 285 000 | 3 420 000 |\n\n## Månedlig fordeling (P50)\n\n| Komponent | Kostnad (NOK/mnd) |\n|-----------|-------------------|\n| Azure AI Services (OCR + classification) | 64 000 |\n| Azure OpenAI (forklaringsmodell) | 28 000 |\n| Azure AI Search (indeks for objektregister) | 12 000 |\n| Storage (blob + cosmos for audit) | 8 500 |\n| Compute (Container Apps for orchestration) | 11 000 |\n| Networking (Private Endpoints + egress) | 5 200 |\n| Monitoring (Sentinel + Log Analytics) | 9 800 |\n| Backup og DR | 3 500 |\n\n## TCO-tabell (3 år)\n\n| År | Capex | Opex | Total | Akkumulert |\n|----|-------|------|-------|------------|\n| År 1 | 850 000 | 1 704 000 | 2 554 000 | 2 554 000 |\n| År 2 | 120 000 | 1 875 000 | 1 995 000 | 4 549 000 |\n| År 3 | 80 000 | 2 060 000 | 2 140 000 | 6 689 000 |\n\n## Kostnadsdrivere\n\n- Datavolum: ~12 millioner Acme Kunde-chatbot-deteksjoner/mnd\n- Forklaring-prompt-tokens: ~250 tokens per flagged hendelse\n- Reservert kapasitet for 99.9% SLA\n\n## Konfidensgradering\n\nP50 er beregnet med 95% konfidens basert på 6 måneder pilot-data. P90 inkluderer 2× volum-skalering ved fullnasjonal utrulling. P10 forutsetter optimaliserte prompt-cache (>40% hit-rate).\n\n## Anbefaling\n\nBruk P50 som budsjettlinje. Sett alarm på 1.4× P50 (≈ 200 000/mnd) for tidlig varsling.\n"
},
"license": {
"input": {},
"raw_markdown": "# Lisens-kapabilitetsmatrise — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nVurderingsdato: 2026-04-30\n\n## Matrise\n\n| Kapabilitet | M365 E3 | M365 E5 | Copilot for M365 | Copilot Studio | Azure AI Foundry |\n|-------------|---------|---------|------------------|----------------|------------------|\n| OCR av objekt-ID | missing | missing | missing | conditional | available |\n| Custom modell-trening | missing | missing | missing | missing | available |\n| Audit-logging på AI-input | missing | available | available | available | available |\n| Customer-managed keys | missing | available | conditional | conditional | available |\n| Private Endpoints | missing | available | missing | conditional | available |\n| saksbehandler-co-pilot UI | missing | missing | available | available | conditional |\n| Norsk språkstøtte i prompts | available | available | available | available | available |\n| Compliance-pakke for leverandøren | missing | available | conditional | conditional | available |\n| Real-time inference (<100ms) | missing | missing | missing | missing | available |\n| Batch-inference for nattlige jobber | missing | missing | missing | missing | available |\n\n## Status-betydning\n\n- available: Inkludert i lisensen, klar til bruk\n- cost: Tilgjengelig som tillegg, krever separat fakturering\n- conditional: Kan brukes med begrensninger eller add-on\n- missing: Ikke tilgjengelig på dette lisensnivået\n\n## Sammendrag\n\nAzure AI Foundry er eneste lisens som dekker alle kjernekapabiliteter. Copilot Studio passer for saksbehandler-UI men kan ikke håndtere OCR/custom modeller alene. Hybrid: Foundry (kjerne) + Copilot Studio (UI) gir best dekning.\n\n## Anbefaling\n\nBruk Azure AI Foundry for AI-tjenester (OCR, klassifisering, forklaring). Hold M365 E5 på saksbehandler-arbeidsstasjoner for audit-logging og compliance-pakke. Vurder Copilot Studio i fase 2 for saksbehandler-co-pilot.\n"
},
"migrate": {
"input": {},
"raw_markdown": "# Migrasjonsplan — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nFra: On-prem OCR + manuell klassifisering\nTil: Azure AI Foundry + saksbehandler-co-pilot\n\n## Faser\n\n### Fase 1 — Foundry-fundament (uker 1-6)\n\nVarighet: 6 uker\nStatus: done\n\nMilepæler:\n- Hub + projects opprettet i West Europe\n- Network isolation: Private Endpoints + Vnet integration\n- Identity: Entra ID-integrasjon med PIM\n- Logging: OpenTelemetry → Sentinel pipeline\n\nSuksesskriterier:\n- Pilot OCR-modell deployert med <100ms latency P95\n- Audit-logg fanger 100% av inferences\n- Sikkerhetsarkitekt godkjenner foundation-design\n\n### Fase 2 — Modell-trening og baseline (uker 7-14)\n\nVarighet: 8 uker\nStatus: done\n\nMilepæler:\n- Treningsdata kuratert (200k norske objekt-ID, stratifisert)\n- Custom modell trent på Azure ML\n- Baseline-nøyaktighet etablert (mål: ≥96% F1)\n- Bias-evaluering på utenlandske registre fullført\n\nSuksesskriterier:\n- F1 ≥ 96% overall, ≥ 92% per objekter-segment\n- Drift-deteksjon kalibrert med terskel\n- ROS-revisjon godkjent\n\n### Fase 3 — saksbehandler-co-pilot (uker 15-22)\n\nVarighet: 8 uker\nStatus: active\n\nMilepæler:\n- Forklaringsmodell (GPT-4 Turbo) integrert via Foundry\n- saksbehandler-UI bygget (Copilot Studio + Power Platform)\n- Workflow: AI flagger → saksbehandler reviewer → klar for sanksjon\n- Brukertest med 12 saksbehandler fra ulike regioner\n\nSuksesskriterier:\n- Saksbehandlingstid -40% vs baseline\n- saksbehandler-tillit >7/10 i post-pilot survey\n- Ingen kritiske UX-feil\n\n### Fase 4 — Compliance og produksjonssetting (uker 23-28)\n\nVarighet: 6 uker\nStatus: planned\n\nMilepæler:\n- FRIA gjennomført og godkjent\n- Conformity assessment ferdigstilt per Annex VI\n- DPIA oppdatert med nye operasjonelle data\n- Produksjonssetting til 3 piloter (Oslo, Bergen, Trondheim)\n\nSuksesskriterier:\n- Personvernombud signerer DPIA\n- Ingen open critical-funn fra arkitekturgjennomgang\n- Stabil 99.9% uptime i 30 dager pilot\n\n## Risiko\n\n| Risiko | Sannsynlighet | Konsekvens | Tiltak |\n|--------|---------------|------------|--------|\n| Custom modell underyter mot 96% mål | medium | high | Backup-strategi: bruk Azure AI Vision OCR som fallback |\n| saksbehandler-motstand mot AI | medium | medium | Tidlig involvering; transparent forklaring; opt-out på enkelt-saker |\n| FRIA blokkerer fase 4 | low | high | Pre-FRIA-kjøring i fase 2 for tidlig varsling |\n| Cost-overrun ved skalering | medium | medium | Reserved capacity-binding etter fase 3 |\n\n## Total varighet\n\n28 uker (~7 måneder). Avhengighet: Foundry-fundament må være ferdig før modell-trening starter.\n"
},
"adr": {
"input": {},
"raw_markdown": "# ADR-001 — Velg Azure AI Foundry som primær AI-plattform for Acme Kunde-chatbot\n\nStatus: accepted\nDate: 2026-04-30\nDeciders: AI-arkitekt, sikkerhetsarkitekt, seksjonsleder\nConsulted: Datatilsynet, juridisk rådgiver, Drift\nInformed: prosjekteierskap, AI-teamet\n\n## Context and Problem Statement\n\nAcme Kommune skal modernisere Acme Kunde-chatbot fra on-prem OCR-løsning til skybasert AI-plattform. Plattformen må støtte custom modell-trening, audit-logging på inferens-nivå, real-time inferens (<100ms P95), og full compliance med EU AI Act + GDPR + sikkerhetsloven.\n\n## Decision Drivers\n\n- Compliance med EU AI Act høyrisiko-krav (Art. 9-15)\n- Norsk dataresidens-krav\n- Customer-managed keys og Private Endpoints\n- Custom modell-trening kapabilitet\n- Total cost of ownership over 3 år\n- Driftbarhet for AI-teamet\n\n## Considered Options\n\n1. **Azure AI Foundry** — Enterprise AI-plattform med full compliance-pakke\n2. **Azure ML + AKS** — Mer kontroll, men høyere driftskost\n3. **AWS SageMaker** — Konkurransedyktig, men mangler norske compliance-sertifiseringer\n4. **On-prem GPU-cluster** — Maks kontroll, men krever betydelig CapEx og driftskompetanse\n\n## Decision Outcome\n\nChosen option: **Azure AI Foundry**, fordi det balanserer compliance, driftbarhet, og fleksibilitet best for vår bemanning og tidsramme.\n\n### Consequences\n\n- Good: full compliance-pakke for leverandøren, raskere time-to-prod, integrert med eksisterende Entra ID\n- Good: customer-managed keys og Customer Lockbox tilgjengelig\n- Bad: lock-in til Azure, men mitigert via standardiserte modell-formater (ONNX) og data-portabilitet\n- Bad: høyere månedlig kostnad enn ren Azure ML — kompenseres ved redusert egen-drift\n\n## Validation\n\nBeslutning evalueres etter 12 måneder mot KPI-er:\n- Saksbehandlingstid (mål: -40%)\n- Modell-nøyaktighet (mål: ≥96% F1)\n- Total cost (mål: ≤ NOK 1.7M/år)\n- Compliance-status (mål: 100% av krav dekket innen 2027-08-02)\n\n## More Information\n\n- Compare-rapport: see `compare-foundry-vs-aml.md`\n- Cost-analyse: see `cost-tco-3year.md`\n- Security-vurdering: see `security-foundry-baseline.md`\n"
},
"summary": {
"input": {},
"raw_markdown": "# Beslutningsnotat — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nDato: 2026-04-30\nTil: Direktør for Digital og IT\nFra: AI-teamet\n\n## Verdict\n\nVerdict: warning\nSub: Pilot anbefalt med betingelser\n\n## Rationale\n\nArkitekturen er teknisk solid og økonomisk forsvarlig (P50 NOK 1.7M/år), men compliance-arbeidet ligger 6 måneder bak ideell tidslinje. Pilot kan starte etter at FRIA og transparens-instruksjoner er ferdigstilt; full produksjonssetting krever lukking av alle critical funn fra arkitekturgjennomgang.\n\n## Key Metrics\n\n| Metric | Verdi | Mål |\n|--------|-------|-----|\n| Compliance-dekning | 33% (4/12 fullt møtt) | 100% innen 2027-08-02 |\n| Sikkerhetsscore | 22/30 (73%) | ≥27/30 (90%) |\n| TCO 3 år | NOK 6.7M | ≤ NOK 7M |\n| Saksbehandlingstid (pilot) | -32% (estimert) | -40% |\n| ROS-restrisiko | medium | low-medium |\n\n## Next Steps\n\n- Lukk F-01 (ABAC) innen 2026-06-15\n- Gjennomfør FRIA innen 2026-07-15 (Art. 27-frist)\n- Produksjonsdokumentere transparens-instruksjoner innen 2026-09-01\n- Pilot 3 regioner (Oslo, Bergen, Trondheim) Q4 2026\n- Full utrulling Q2 2027\n\n## Restrisiko\n\nEtter foreslåtte tiltak: medium. Hovedeksponering: bias mot utenlandske objekt-ID krever løpende monitoring.\n\n## Anbefaling\n\nGodkjenn pilot-fase med tydelig stage-gate til full produksjonssetting. Avstem med Datatilsynet før fase 4.\n"
},
"poc": {
"input": {},
"raw_markdown": "# POC-plan — Acme Kunde-chatbot\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nPOC-mål: Validere at Azure AI Foundry kan dekke OCR + forklaring + audit innen tids- og kostbudsjett\n\n## Faser\n\n### Fase 1 — Foundation (uker 1-2)\n\nVarighet: 2 uker\nStatus: done\n\nMilepæler:\n- Foundry hub + project i West Europe\n- Identity og networking konfigurert\n- Sample-data uploadet (10k anonymiserte objekt-ID)\n\nSuksesskriterier:\n- Inferens-endpoint nåbart fra dev-Vnet via Private Endpoint\n- Audit-logg fanger første test-inferens\n- Cost-monitor viser daglig forbruk i Azure portal\n\n### Fase 2 — OCR-modell (uker 3-5)\n\nVarighet: 3 uker\nStatus: active\n\nMilepæler:\n- Pre-trent Azure AI Vision OCR pilotert\n- Custom fine-tune på 10k objekt-ID\n- Sammenligning av accuracy/latency mellom de to\n\nSuksesskriterier:\n- F1 ≥ 92% på pilot-sett (lavere mål enn produksjon, akseptabelt for POC)\n- Latency P95 < 200ms\n- Inference-cost ≤ NOK 0.04 per kall\n\n### Fase 3 — Forklarings-loop (uker 6-7)\n\nVarighet: 2 uker\nStatus: planned\n\nMilepæler:\n- GPT-4 Turbo via Foundry integrert\n- Prompt-template for forklaring av flagged sak\n- saksbehandler-mock UI (en enkel webside) prøvd ut med 3 brukere\n\nSuksesskriterier:\n- Forklaring referer til konfidens og kontekst korrekt i 95% av tilfellene\n- saksbehandler-feedback kvalitativt positiv (\"forståelig, men trenger justering\")\n- Prompt-tokens under 250 i snitt per sak\n\n### Fase 4 — Compliance-pre-check (uke 8)\n\nVarighet: 1 uke\nStatus: planned\n\nMilepæler:\n- Audit-logg mot EU AI Act Art. 12-krav\n- Customer-managed keys verifisert\n- Pre-DPIA-sjekk gjort med Datatilsynet\n\nSuksesskriterier:\n- Audit-logg dekker 100% av inferences med tidsstempel + bruker\n- Personvernombud signer pre-DPIA-utkast\n- Ingen åpenbare GDPR-blokkere\n\n## Risiko\n\n| Risiko | Sannsynlighet | Konsekvens | Tiltak |\n|--------|---------------|------------|--------|\n| Custom OCR-modell underyter pre-trent | medium | medium | Aksepter pre-trent for POC; planlegg custom for full prod |\n| Foundry-quota i West Europe utilstrekkelig | low | medium | Reserver kapasitet før POC starter |\n| saksbehandler-recruitment forsinker fase 3 | medium | low | Bruk interne ressurser i AI-teamet som mock |\n| Audit-logg-format ikke kompatibelt med Sentinel | low | medium | Test integrasjon i fase 1 |\n\n## POC-Verdict: BETINGET\n\nPilot-fase 1 fullført med F1=0.94 og inference-cost 0.038 NOK/kall (under budsjett). Fase 2 pågår — sammenligning av custom fine-tune mot pre-trent OCR i progress. Forklarings-loop og compliance-pre-check planlagt for siste halvdel.\n\n## Total varighet\n\n8 uker. Beslutningskriterium for full prosjektgodkjenning: alle 4 fasers suksesskriterier møtt.\n"
},
"utredning": {
"input": {},
"raw_markdown": "# AI-arkitekturutredning — Acme Kunde-chatbot for Acme Kommune\n\n## 1. Bakgrunn og formål\n\nAcme Kommune har siden 2018 driftet en on-prem Acme Kunde-chatbot-løsning for operasjonell analyse på tvers av leverandørens tjenesteportefølje. Løsningen er basert på et OCR-bibliotek fra 2017 og leveres som et lukket system uten mulighet for retrening eller forbedring av modell. Saksbehandlingen er manuell og tar i snitt 14 minutter per sak. Et internt AI-team utreder modernisering til en skybasert AI-plattform som støtter custom modell-trening, audit-logging på inferens-nivå, og saksbehandler-co-pilot.\n\n## 2. Mandat\n\nUtredningen skal:\n- Anbefale teknologivalg blant Azure AI Foundry, Azure ML+AKS, AWS SageMaker og on-prem GPU-cluster\n- Vurdere compliance-status mot EU AI Act, GDPR, sikkerhetsloven og arkivloven\n- Estimere TCO over 3 år\n- Identifisere risiko og foreslå mitigerende tiltak\n- Definere KPI-er for produksjonssetting\n\n## 3. Metode\n\nUtredningen kombinerer:\n- Kvalitativ analyse av compliance-krav per relevante lover og forskrifter\n- Kvantitativ TCO-analyse basert på 12 millioner Acme Kunde-chatbot-deteksjoner/mnd\n- Risikoanalyse per NS 5814 og DPIA per Datatilsynets veileder\n- Markedsundersøkelse av tilgjengelige plattformer fra Azure, AWS og GCP\n\n## 4. Funn\n\n### 4.1 Compliance\n\nEU AI Act klassifiserer systemet som høyrisiko (Annex III, punkt 6 — rettshåndhevelse). Acme Kommune er Provider og Deployer, hvilket trigger alle krav i Art. 9-15 + Art. 27 (FRIA) + Art. 49 (registrering).\n\n### 4.2 Teknologivalg\n\nAzure AI Foundry er anbefalt primær plattform fordi:\n- Full compliance-pakke for leverandøren\n- Customer-managed keys og Customer Lockbox tilgjengelig\n- Custom modell-trening via integrert Azure ML\n- Norsk dataresidens (West Europe + EU Data Boundary)\n\n### 4.3 TCO\n\n3-års TCO estimert til NOK 6.7M (P50). Hovedkostnad: Azure AI Services (38%) + Azure OpenAI (16%).\n\n### 4.4 Risiko\n\nHovedrisiko: bias mot utenlandske objekt-ID, modell-drift over tid, og manglende ABAC-implementering på saksbehandler-tilgang. Alle har konkrete tiltak.\n\n## 5. Konklusjon\n\nAnbefalt: gjennomfør 8-ukers POC før formell prosjektoppstart. Ved vellykket POC, full implementering over 28 uker mot produksjonssetting Q2 2027.\n\n## 6. Anbefaling\n\nGodkjenn POC-budsjett på NOK 1.2M og forenkle prosjekt-mandat for fase 1-4 ved positiv POC-evaluering.\n\n## 7. Referanser\n\n- EU AI Act 2024/1689\n- GDPR 2016/679\n- Sikkerhetsloven (LOV-2018-06-01-24)\n- Arkivloven (LOV-1992-12-04-126)\n- NS 5814:2008 — Krav til risikovurderinger\n- Datatilsynets veileder for AI og personvern (2024)\n"
},
"compare": {
"input": {},
"raw_markdown": "# Sammenligning — Azure AI Foundry vs Azure ML + AKS\n\nSystem: Acme Kunde-chatbot (Acme Kommune)\nSammenligningsdato: 2026-04-30\n\n## Subjects\n\nSubject 1: Azure AI Foundry\nSubject 2: Azure ML + AKS\n\n## Sammenligning\n\n| Aspekt | Azure AI Foundry | Azure ML + AKS | Vinner |\n|--------|------------------|----------------|--------|\n| Time-to-prod | 6-8 uker for fundament | 12-16 uker | Foundry |\n| Custom modell-trening | Integrert via Azure ML under panseret | Direkte Azure ML | Lik |\n| Compliance-pakke for leverandøren | Inkludert | Må bygges selv | Foundry |\n| Driftbarhet for AI-teamet | Lav driftbyrde, mest klikk-ops | Høy driftbyrde, full DevOps | Foundry |\n| Fleksibilitet for custom infrastruktur | Begrenset til Foundry-mønstre | Full kontroll over AKS-cluster | Azure ML + AKS |\n| Audit-logging på inferens | Innebygd | Må konfigureres manuelt | Foundry |\n| Customer-managed keys | Tilgjengelig | Tilgjengelig | Lik |\n| Customer Lockbox | Tilgjengelig | Tilgjengelig | Lik |\n| Private Endpoints | Tilgjengelig | Tilgjengelig | Lik |\n| Real-time inferens (<100ms) | Tilgjengelig via Foundry endpoints | Tilgjengelig via AKS | Lik |\n| Total cost (3 år) | NOK 6.7M | NOK 5.9M | Azure ML + AKS |\n| Lock-in til Azure | Høy | Medium (mer portabilitet i AKS) | Azure ML + AKS |\n| Forklaringsmodell-integrasjon | Native Foundry-integrasjon | Krever egen wrapper | Foundry |\n| Multi-region failover | Innebygd | Må implementeres manuelt | Foundry |\n\n## Sammendrag\n\nAzure AI Foundry vinner på time-to-prod, compliance-pakke, og driftbarhet. Azure ML + AKS vinner på pris (-12%) og fleksibilitet. Differansen i pris (~NOK 800k over 3 år) er liten sammenlignet med besparelsen i drift-tid for AI-teamet.\n\n## Vinner: Azure AI Foundry\n\n## Anbefaling\n\nFor Acme Kommune med begrenset KI-driftkapasitet anbefales Azure AI Foundry. For organisasjoner med dedikert MLOps-team kan Azure ML + AKS gi marginalt bedre kost-nytte.\n\n## Kontekst\n\nBeslutningen er sterkere drevet av compliance og driftbarhet enn ren kostnad. Foundry's leverandøren-pakke sparer 8-12 uker arbeid med å sertifisere baseline-konfigurasjonen.\n"
}
}
}
],
"activeProjectId": "acme-kunde-chatbot",
"activeSurface": "project",
"preferences": {
"theme": "dark"
}
}
</script>
<!--
Klassisk script (ikke type="module") av to grunner:
1. External <script type="module" src="..."> feiler på file:// i Chrome+Firefox
(ref WHATWG html#8121, Chromium 41378227).
2. Single-file deployment per brief Constraints — ingen build-step.
Kommende steps utvider IIFE-en under: Step 2 (state-modul), Step 3 (eksport/import),
Step 4 (CATALOG), osv.
-->
<script>
(function () {
'use strict';
// localStorage-nøkkel og schema-versjon. Endring av STATE_KEY krever migrasjons-steg
// (se Step 3 — MIGRATIONS-pipeline). SCHEMA_VERSION bumpes ved breaking endringer
// i state-form og driver eager migrations ved import.
const STATE_KEY = 'ms-ai-architect-state-v1';
const SCHEMA_VERSION = 1;
// Eksponer som globals for Verify-asserts og DevTools-debugging. Senere steps
// utvider window.__-namespace med __store, __CATALOG, __PARSERS, __RENDERERS,
// __buildCommand, __buildEnvelope, __handlePasteImport.
window.__STATE_KEY = STATE_KEY;
window.__SCHEMA_VERSION = SCHEMA_VERSION;
// ============================================================
// STATE MODULE (Step 2)
// ============================================================
//
// Reactivity-skjelett: Proxy + EventTarget. set-trap batcher dispatchEvent
// via queueMicrotask, så N synkrone mutasjoner gir bare én 'change'-event
// per mikrotask-tick. Bruker dyp wrap (Proxy rekursivt på objekt-properties)
// så nestede oppdateringer (state.shared.organization.name = ...) fanges.
//
// Persistens: IDB primær (~ubegrenset for 1-5 MB), localStorage fallback
// (5 MiB cap). Open-DB pattern bruker Promise-wrapper og synkrone
// migrasjoner i onupgradeneeded — async cursor-iterasjon er forbudt
// (ref w3c/IndexedDB#282: korrupsjons-risiko ved async i upgrade-tx).
//
// Multi-tab: db.onversionchange = () => db.close() defensivt på alle
// koblinger så en versjon-bump i en annen tab ikke stuck-blokkerer denne.
class StateBus extends EventTarget {}
const sharedBus = new StateBus();
const projectBus = new StateBus();
// Initial state-form. Step 5+ utvider shared.* etter onboarding-skjema;
// Step 7 utvider projects[]. preferences.theme settes i Step 13.
const INITIAL_STATE = {
schemaVersion: SCHEMA_VERSION,
shared: {
organization: {},
technology: {},
security: {},
architecture: {},
business: {}
},
projects: [],
activeProjectId: null,
activeSurface: 'home',
preferences: { theme: 'dark' }
};
// Microtask-batched event dispatcher. Mange synkrone set-traps i samme
// tick → én 'change'-event neste mikrotask. Forhindrer N renders ved
// batch-mutasjoner (f.eks. import-flow).
function makeBatchedDispatcher(bus) {
let pending = false;
const changedPaths = new Set();
return function dispatch(path) {
changedPaths.add(path);
if (pending) return;
pending = true;
queueMicrotask(function () {
pending = false;
const paths = Array.from(changedPaths);
changedPaths.clear();
bus.dispatchEvent(new CustomEvent('change', { detail: { paths: paths } }));
});
};
}
// Dyp Proxy-wrap. Lazy: wrapper child-objekter ved første read, så cost
// er bare betalt for grener brukeren faktisk berører. set-trap returnerer
// boolean (Proxy spec-invariant) og dispatcher batched 'change'-event.
// Path tracking gjør at subscribers kan filtrere på relevante grener.
function deepProxy(target, dispatch, path) {
path = path || '';
const cache = new WeakMap();
const handler = {
get: function (obj, key) {
const value = obj[key];
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
if (cache.has(value)) return cache.get(value);
const childPath = path ? path + '.' + String(key) : String(key);
const wrapped = new Proxy(value, makeHandler(childPath));
cache.set(value, wrapped);
return wrapped;
}
if (Array.isArray(value)) {
// Array-mutasjoner via push/splice trigger set på indekser; wrap likt.
if (cache.has(value)) return cache.get(value);
const childPath = path ? path + '.' + String(key) : String(key);
const wrapped = new Proxy(value, makeHandler(childPath));
cache.set(value, wrapped);
return wrapped;
}
return value;
},
set: function (obj, key, value) {
obj[key] = value;
dispatch(path ? path + '.' + String(key) : String(key));
return true;
},
deleteProperty: function (obj, key) {
delete obj[key];
dispatch(path ? path + '.' + String(key) : String(key));
return true;
}
};
function makeHandler(p) {
return {
get: function (o, k) { return new Proxy(target, handler).constructor === Proxy ? handler.get(o, k) : o[k]; },
set: function (o, k, v) { o[k] = v; dispatch(p ? p + '.' + String(k) : String(k)); return true; },
deleteProperty: function (o, k) { delete o[k]; dispatch(p ? p + '.' + String(k) : String(k)); return true; }
};
}
return new Proxy(target, handler);
}
function createStore(initial, bus) {
const dispatch = makeBatchedDispatcher(bus);
const proxied = deepProxy(initial, dispatch, '');
return {
state: proxied,
raw: initial, // referanse til underliggende objekt — for serialisering
subscribe: function (handler) { bus.addEventListener('change', handler); },
unsubscribe: function (handler) { bus.removeEventListener('change', handler); }
};
}
// Throttled persistens: debounce 300 ms etter siste mutasjon, så bursts
// (import-flow, batch-form-submit) committer bare én gang.
function makeThrottledWriter(persist) {
let timer = null;
return function schedule() {
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
timer = null;
persist().catch(function (err) {
console.error('[playground v3] persist failed:', err);
});
}, 300);
};
}
// ============================================================
// PERSISTENCE LAYER
// ============================================================
//
// IDB primær. Én DB ('ms-ai-architect-playground-v1') med to object-stores:
// - 'shared': nøkkel 'shared' → { organization, technology, ... }
// - 'projects': nøkkel 'projectId' → project-objekt
//
// Migrasjoner i onupgradeneeded er SYNKRONE per spec — ingen await på
// cursor.continue(); bruk callback-stil. async cursor-iterasjon i en
// upgrade-tx kan korruptere DB (w3c/IndexedDB#282).
function openDB(name, version) {
return new Promise(function (resolve, reject) {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB ikke tilgjengelig'));
return;
}
const req = indexedDB.open(name, version);
req.onupgradeneeded = function (ev) {
const db = req.result;
const oldVersion = ev.oldVersion;
// Synkrone migrasjoner — opprette stores per oldVersion-guard.
if (oldVersion < 1) {
if (!db.objectStoreNames.contains('shared')) {
db.createObjectStore('shared');
}
if (!db.objectStoreNames.contains('projects')) {
db.createObjectStore('projects', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta');
}
}
// Senere bump-er legges til som "if (oldVersion < N)"-blokker.
};
req.onsuccess = function () {
const db = req.result;
// Defensiv multi-tab: hvis annen tab åpner med høyere versjon,
// lukk denne så de ikke blokkerer hverandre.
db.onversionchange = function () {
db.close();
console.warn('[playground v3] IDB versionchange — closed for upgrade');
};
resolve(db);
};
req.onerror = function () { reject(req.error); };
req.onblocked = function () {
// En annen tab holder en eldre versjon åpen; usannsynlig i v3
// (én DB-versjon per release), men logg likevel.
console.warn('[playground v3] IDB open blocked — another tab holds older version');
};
});
}
// Primær persistens: IDB. Ved feil (Safari private mode, kvote-overflow)
// fallback til localStorage. Returnerer adapter med lik API — kallere
// trenger ikke vite hvilken backend som er i bruk.
async function makePersistence() {
const DB_NAME = 'ms-ai-architect-playground-v1';
const DB_VERSION = 1;
try {
const db = await openDB(DB_NAME, DB_VERSION);
return {
backend: 'idb',
load: function () {
return new Promise(function (resolve, reject) {
const tx = db.transaction(['shared', 'projects', 'meta'], 'readonly');
const sharedReq = tx.objectStore('shared').get('shared');
const projectsReq = tx.objectStore('projects').getAll();
const metaReq = tx.objectStore('meta').get('meta');
tx.oncomplete = function () {
resolve({
schemaVersion: (metaReq.result && metaReq.result.schemaVersion) || SCHEMA_VERSION,
shared: sharedReq.result || INITIAL_STATE.shared,
projects: projectsReq.result || [],
activeProjectId: (metaReq.result && metaReq.result.activeProjectId) || null,
activeSurface: (metaReq.result && metaReq.result.activeSurface) || 'home',
preferences: (metaReq.result && metaReq.result.preferences) || INITIAL_STATE.preferences
});
};
tx.onerror = function () { reject(tx.error); };
});
},
save: function (state) {
return new Promise(function (resolve, reject) {
const tx = db.transaction(['shared', 'projects', 'meta'], 'readwrite');
tx.objectStore('shared').put(state.shared, 'shared');
const projectStore = tx.objectStore('projects');
// Clear-and-rewrite er enkelt og atomær for moderate volum.
// Ved >100 prosjekter bør dette switch-es til diff-write.
projectStore.clear();
for (let i = 0; i < state.projects.length; i++) {
projectStore.put(state.projects[i]);
}
tx.objectStore('meta').put({
schemaVersion: state.schemaVersion,
activeProjectId: state.activeProjectId,
activeSurface: state.activeSurface,
preferences: state.preferences
}, 'meta');
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
}
};
} catch (err) {
console.warn('[playground v3] IDB ikke tilgjengelig, faller tilbake til localStorage:', err && err.message);
return makeLocalStorageFallback();
}
}
function makeLocalStorageFallback() {
return {
backend: 'localStorage',
load: function () {
try {
const raw = localStorage.getItem(STATE_KEY);
if (!raw) return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
return Promise.resolve(JSON.parse(raw));
} catch (err) {
console.error('[playground v3] localStorage parse-feil, returnerer initial state:', err);
return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
}
},
save: function (state) {
try {
const payload = JSON.stringify(state);
// Cap-advarsel: localStorage 5 MiB cap. Ved ~4.5 MB warn brukeren.
if (payload.length > 4.5 * 1024 * 1024) {
console.warn('[playground v3] State nærmer seg localStorage 5 MiB cap. Bruk en moderne nettleser med IDB-støtte.');
}
localStorage.setItem(STATE_KEY, payload);
return Promise.resolve();
} catch (err) {
return Promise.reject(err);
}
}
};
}
// ============================================================
// BOOTSTRAP
// ============================================================
//
// Initialiser persistens, last lagret state, opprett store, hook opp
// throttled writer. Eksponer __store på window for Verify-asserts og
// DevTools — Step 3 utvider med __buildEnvelope, Step 4 med __CATALOG.
let store = null;
let persistence = null;
let scheduleWrite = null;
async function bootstrap() {
persistence = await makePersistence();
const loaded = await persistence.load();
// Sørg for at schemaVersion finnes (cold start kan returnere uten).
if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION;
// Data-version migrasjon (v1.9.0 -> v1.10.0): utled verdict/keyStats
// for eksisterende parser-output. Idempotent via state.dataVersion-guard.
try { migrateDataVersion(loaded, defaultArchetypeFor); }
catch (e) { console.warn('[playground v3] migrateDataVersion failed:', e); }
store = createStore(loaded, sharedBus);
scheduleWrite = makeThrottledWriter(function () {
return persistence.save(store.raw);
});
store.subscribe(function () { scheduleWrite(); });
window.__store = store;
window.__persistence = persistence;
// Initial-surface heuristikk: hvis onboarding aldri er gjort (ingen
// organisasjons-navn) og state ikke har eksplisitt valg fra forrige
// sesjon, gå til onboarding. Ellers bruk lagret activeSurface.
const orgName = store.state.shared && store.state.shared.organization && store.state.shared.organization.name;
if (!orgName) store.state.activeSurface = 'onboarding';
else if (!store.state.activeSurface) store.state.activeSurface = 'home';
scheduleRender();
}
// ============================================================
// EXPORT / IMPORT (Step 3)
// ============================================================
//
// Brukeren kan eksportere hele state som JSON-fil og re-importere på en
// annen enhet (eller etter localStorage.clear()). Format er en envelope
// med schemaVersion + appId — så fremtidige versjoner kan lese gamle
// eksporter via MIGRATIONS-pipeline.
//
// File System Access API krever HTTPS (secure context) og er ikke
// tilgjengelig på file:// — vi bruker Blob + URL.createObjectURL +
// <a download> for eksport, og <input type="file"> + File.text() for
// import. Begge fungerer på file:// i alle target-browsers.
//
// MIGRATIONS er en eager pipeline: ved import (eller cold-load fra
// gammel state) kjøres alle migrasjoner sekvensielt fra fil-versjon til
// gjeldende SCHEMA_VERSION. Aldri hopp over et steg — selv tomme
// migrasjoner skal være registrert (no-op) for å bevise at hoppet er
// håndtert.
const APP_ID = 'ms-ai-architect-playground';
function buildEnvelope() {
// Snapshot av rå state. JSON.stringify(JSON.parse(...)) sørger for
// at Proxy-er er stripped; vi vil ikke at envelopet skal beholde
// wrapper-referanser.
const snapshot = store ? JSON.parse(JSON.stringify(store.raw)) : JSON.parse(JSON.stringify(INITIAL_STATE));
return {
appId: APP_ID,
schemaVersion: snapshot.schemaVersion || SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
shared: snapshot.shared,
projects: snapshot.projects,
activeProjectId: snapshot.activeProjectId,
activeSurface: snapshot.activeSurface,
preferences: snapshot.preferences
};
}
function exportState() {
const envelope = buildEnvelope();
const json = JSON.stringify(envelope, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const stamp = envelope.exportedAt.replace(/[:.]/g, '-');
a.href = url;
a.download = APP_ID + '-' + stamp + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// revokeObjectURL etter at klikket har skjedd; setTimeout 0 er nok i
// alle target-browsers (Blob URL holdes så lenge nedlastningen står
// for å initieres).
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
return envelope;
}
// MIGRATIONS-pipeline. Nøkkel-format: 'N->M' der N og M er fortløpende
// SCHEMA_VERSION-tall. Funksjon tar et state-objekt og returnerer et
// nytt state-objekt på neste versjon. Aldri muter input.
//
// Når SCHEMA_VERSION bumpes til 2: legg til '1->2'-funksjon som
// transformerer v1-state til v2-form. importState plukker opp
// migrasjonen automatisk.
const MIGRATIONS = {
// Eksempel for fremtid (no-op stub):
// '1->2': function (state) { return Object.assign({}, state, { schemaVersion: 2 }); }
};
function migrateState(state) {
let current = state;
let from = current.schemaVersion || 1;
while (from < SCHEMA_VERSION) {
const key = from + '->' + (from + 1);
const fn = MIGRATIONS[key];
if (!fn) {
throw new Error('[playground v3] mangler migrasjon ' + key + ' — kan ikke trygt oppgradere import-fil');
}
current = fn(current);
if (current.schemaVersion !== from + 1) {
throw new Error('[playground v3] migrasjon ' + key + ' satte ikke schemaVersion til ' + (from + 1));
}
from = current.schemaVersion;
}
return current;
}
async function importState(file) {
// file er File-objekt fra <input type="file"> change-event.
// file.text() er Promise<string> — fungerer på file:// uten secure context.
const text = await file.text();
let envelope;
try {
envelope = JSON.parse(text);
} catch (err) {
throw new Error('Ugyldig JSON: ' + err.message);
}
if (envelope.appId !== APP_ID) {
throw new Error('Fil-en er ikke en ' + APP_ID + '-eksport (appId=' + envelope.appId + ')');
}
if (typeof envelope.schemaVersion !== 'number') {
throw new Error('Mangler schemaVersion i envelope');
}
// Migrer envelope opp til gjeldende SCHEMA_VERSION før vi commit-er.
const migrated = migrateState({
schemaVersion: envelope.schemaVersion,
dataVersion: envelope.dataVersion,
shared: envelope.shared || INITIAL_STATE.shared,
projects: envelope.projects || [],
activeProjectId: envelope.activeProjectId || null,
activeSurface: envelope.activeSurface || 'home',
preferences: envelope.preferences || INITIAL_STATE.preferences
});
// Data-version migrasjon (additive — verdict/keyStats utledes for
// pre-v1.10.0 envelope-er). Idempotent via dataVersion=2 guard.
try { migrateDataVersion(migrated, defaultArchetypeFor); }
catch (e) { console.warn('[playground v3] migrateDataVersion (import) failed:', e); }
// Skriv direkte til persistens for å unngå at debounce-vinduet
// svelger import-en ved en samtidig page-unload.
if (persistence) {
await persistence.save(migrated);
}
// Erstatt store-state in-place. Vi kan ikke bytte ut store.raw
// sin referanse fordi Proxy-en er bundet til den; muter feltvis.
const target = store.raw;
target.schemaVersion = migrated.schemaVersion;
if (migrated.dataVersion != null) target.dataVersion = migrated.dataVersion;
target.shared = migrated.shared;
target.projects = migrated.projects;
target.activeProjectId = migrated.activeProjectId;
target.activeSurface = migrated.activeSurface;
target.preferences = migrated.preferences;
// Trigger en change-event manuelt så subscribers re-rendrer.
sharedBus.dispatchEvent(new CustomEvent('change', { detail: { paths: ['*'] } }));
return migrated;
}
// Eksponer for UI-handlere (Step 5+) og DevTools-debugging.
window.__buildEnvelope = buildEnvelope;
window.__exportState = exportState;
window.__importState = importState;
window.__MIGRATIONS = MIGRATIONS;
// ============================================================
// COMMAND CATALOG (Step 4)
// ============================================================
//
// Kanonisk single-source-of-truth for alle 24 commands. Driver:
// - Step 5/8: skjema-render via input_fields[]
// - Step 9: katalog-UI gruppert på category
// - Step 11: parser-routing via report_archetype
// - Step 12: renderer-routing via renderer-feltet
// - __buildCommand: pipeline-string-bygging per command
//
// Felles-state-felter har from='shared' + shared_path='group.field'
// (oppslag mot state.shared.<group>.<field>). Lokale felter har
// from='local' og lagres i project.reports[id].input.
//
// Verktøy-commands (architect, help, research, diagram, onboard,
// generate-skills, export) har produces_report=false og null for
// archetype/root/renderer — Step 11/12 hopper over dem.
const FIELD_TYPES = {
TEXT: 'text',
TEXTAREA: 'textarea',
SELECT: 'select',
MULTI_SELECT: 'multiSelect',
BOOLEAN: 'boolean',
NUMBER: 'number'
};
// Felles felt-shorthands. Holder CATALOG kompakt og sikrer at samme
// felles-felt har eksakt samme label/type på tvers av alle commands
// som bruker det.
const SHARED = {
organisation_name: { id: 'organisation_name', label: 'Virksomhet', type: 'text', from: 'shared', shared_path: 'organization.name' },
sector: { id: 'sector', label: 'Sektor', type: 'select', from: 'shared', shared_path: 'organization.sector', options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
regulatory_requirements: { id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'multiSelect', from: 'shared', shared_path: 'organization.regulatory_requirements', options: ['Personopplysningsloven/GDPR', 'Sikkerhetsloven', 'Arkivloven', 'Forvaltningsloven', 'Offentleglova', 'Helseregisterloven', 'Annet'] },
cloud_platform: { id: 'cloud_platform', label: 'Skyplattform', type: 'multiSelect', from: 'shared', shared_path: 'technology.cloud_platform', options: ['Azure', 'M365', 'Power Platform', 'On-prem', 'Hybrid', 'Annet'] },
license_type: { id: 'license_type', label: 'Lisenstype', type: 'select', from: 'shared', shared_path: 'technology.license_type', options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Annet'] },
ai_services_in_use: { id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'multiSelect', from: 'shared', shared_path: 'technology.ai_services_in_use', options: ['Azure OpenAI', 'Copilot for M365', 'Copilot Studio', 'AI Builder', 'Azure AI Search', 'Azure AI Services', 'Ingen', 'Annet'] },
data_classification: { id: 'data_classification', label: 'Dataklassifisering', type: 'multiSelect', from: 'shared', shared_path: 'security.data_classification', options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig', 'Hemmelig'] },
dpia_practice: { id: 'dpia_practice', label: 'DPIA-praksis i organisasjonen', type: 'select', from: 'shared', shared_path: 'security.dpia_practice', options: ['Systematisk', 'Ad hoc', 'Ikke etablert', 'Usikker'] },
annual_ai_budget: { id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'select', from: 'shared', shared_path: 'architecture.annual_ai_budget', options: ['<500k', '500k-2M', '2M-10M', '>10M', 'Ikke definert'] }
};
const PLATFORMS = ['Azure AI Foundry', 'Copilot Studio', 'M365 Copilot', 'Power Automate', 'AI Builder', 'Azure OpenAI', 'Azure AI Search', 'Annet'];
const RISK_LEVELS = ['minimal', 'limited', 'high', 'forbidden', 'ukjent'];
const ORG_ROLES = ['provider', 'deployer', 'distributor', 'importer', 'usikker'];
const CATALOG = {
version: '1.0',
generated_for_schema: SCHEMA_VERSION,
categories: [
{ id: 'regulatory', label: 'Regulatorisk', count: 6 },
{ id: 'security', label: 'Sikkerhet', count: 3 },
{ id: 'economy', label: 'Økonomi', count: 2 },
{ id: 'documentation', label: 'Dokumentasjon', count: 6 },
{ id: 'tool', label: 'Verktøy', count: 7 }
],
commands: [
// ===== REGULATORY (6) =====
{
id: 'classify',
category: 'regulatory',
label: 'EU AI Act — Klassifisering',
description: 'Klassifiser AI-system etter EU AI Act-risikonivå (forbidden/high/limited/minimal) og bestem rolle.',
argument_hint: '[system-beskrivelse]',
calls_agent: 'ai-act-assessor',
kb_files: ['ai-act-classification-methodology.md', 'ai-act-annex-iii-checklist.md', 'ai-act-compliance-guide.md'],
produces_report: true,
report_archetype: 'aiact',
report_root_class: 'pyramide',
renderer: 'renderAiActPyramid',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'users', label: 'Brukere', type: 'text', from: 'local' },
{ id: 'interaction_type', label: 'Interaksjonstype', type: 'select', from: 'local', options: ['chatbot', 'beslutningsstøtte', 'automatisering', 'anbefaling', 'annet'] },
{ id: 'data_sources', label: 'Datakilder', type: 'textarea', from: 'local' },
{ id: 'risk_level_assumption', label: 'Risk-level (forhåndsvurdering)', type: 'select', from: 'local', options: RISK_LEVELS }
]
},
{
id: 'requirements',
category: 'regulatory',
label: 'EU AI Act — Krav per risiko + rolle',
description: 'Konkrete AI Act-forpliktelser basert på klassifisering og rolle (provider/deployer).',
argument_hint: '[system-beskrivelse el. klassifisering]',
calls_agent: 'ai-act-assessor',
kb_files: ['ai-act-provider-obligations.md', 'ai-act-deployer-obligations.md', 'ai-act-microsoft-tools-mapping.md'],
produces_report: true,
report_archetype: 'requirements-list',
report_root_class: 'findings',
renderer: 'renderRequirements',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS },
{ id: 'org_role', label: 'Rolle', type: 'select', from: 'local', options: ORG_ROLES }
]
},
{
id: 'transparency',
category: 'regulatory',
label: 'Transparensnotis (Art. 13/50)',
description: 'Generer Art. 13/50-transparensnotis på norsk for AI-system.',
argument_hint: '[system-beskrivelse]',
calls_agent: 'ai-act-assessor',
kb_files: ['ai-act-transparency-notices.md'],
produces_report: true,
report_archetype: 'text-document',
report_root_class: 'markdown-fallback',
renderer: 'renderTransparency',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'interaction_type', label: 'Interaksjonstype', type: 'select', from: 'local', options: ['chatbot', 'beslutningsstøtte', 'automatisering', 'anbefaling', 'annet'] },
{ id: 'target_audience', label: 'Målgruppe', type: 'text', from: 'local' },
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS }
]
},
{
id: 'frimpact',
category: 'regulatory',
label: 'FRIA (Art. 27)',
description: 'Fundamental Rights Impact Assessment — obligatorisk for offentlig sektor som deployer.',
argument_hint: '[system-beskrivelse]',
calls_agent: 'ai-act-assessor',
kb_files: ['ai-act-fria-template.md', 'ai-act-deployer-obligations.md'],
produces_report: true,
report_archetype: 'fria',
report_root_class: 'rights-matrix',
renderer: 'renderFria',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'affected_groups', label: 'Berørte grupper', type: 'textarea', from: 'local' },
{ id: 'decisions_affected', label: 'Beslutninger som påvirkes', type: 'textarea', from: 'local' },
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS }
]
},
{
id: 'conformity',
category: 'regulatory',
label: 'Samsvarsvurdering (Art. 43)',
description: 'Annex IV-sjekkliste og EU-erklæring for høyrisiko AI-systemer.',
argument_hint: '[system-beskrivelse]',
calls_agent: 'ai-act-assessor',
kb_files: ['ai-act-conformity-assessment.md', 'ai-act-provider-obligations.md'],
produces_report: true,
report_archetype: 'conformity-checklist',
report_root_class: 'findings',
renderer: 'renderConformity',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'risk_classification', label: 'Risikoklassifisering', type: 'select', from: 'local', options: RISK_LEVELS },
{ id: 'org_role', label: 'Rolle', type: 'select', from: 'local', options: ORG_ROLES },
{ id: 'existing_documentation', label: 'Eksisterende dokumentasjon', type: 'textarea', from: 'local' }
]
},
{
id: 'dpia',
category: 'regulatory',
label: 'DPIA / PVK',
description: 'Personvernkonsekvensvurdering for AI-system med risikomatrise og tiltakstabell.',
argument_hint: '[system-beskrivelse]',
calls_agent: 'dpia-agent',
kb_files: ['dpia-norwegian-methodology-ai.md', 'gdpr-compliance-ai-systems.md', 'ai-impact-assessment-framework.md'],
produces_report: true,
report_archetype: 'matrix-risk',
report_root_class: 'matrix',
renderer: 'renderDpia',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
SHARED.dpia_practice,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'personal_data_types', label: 'Typer personopplysninger', type: 'textarea', from: 'local' },
{ id: 'data_subjects', label: 'Registrerte (data subjects)', type: 'text', from: 'local' },
{ id: 'legal_basis', label: 'Behandlingsgrunnlag (GDPR Art. 6)', type: 'select', from: 'local', options: ['Samtykke', 'Avtale', 'Rettslig forpliktelse', 'Vitale interesser', 'Allmenn interesse', 'Berettiget interesse'] },
{ id: 'data_sources', label: 'Datakilder', type: 'textarea', from: 'local' }
]
},
// ===== SECURITY (3) =====
{
id: 'security',
category: 'security',
label: 'Sikkerhetsvurdering (6×5)',
description: 'Sikkerhetsvurdering på 6 dimensjoner med 1-5 score, OWASP LLM Top 10.',
argument_hint: '[plattform] for [bruksscenario]',
calls_agent: 'security-assessment-agent',
kb_files: ['security-scoring-rubrics-6x5.md', 'ai-security-scoring-framework.md', 'ai-threat-modeling-stride.md'],
produces_report: true,
report_archetype: 'matrix-risk-6x5',
report_root_class: 'matrix',
renderer: 'renderSecurity',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
SHARED.cloud_platform,
SHARED.data_classification,
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'use_case', label: 'Bruksscenario', type: 'textarea', from: 'local' },
{ id: 'citizen_facing', label: 'Eksponert for innbyggere?', type: 'boolean', from: 'local' },
{ id: 'data_sources', label: 'Datakilder', type: 'textarea', from: 'local' }
]
},
{
id: 'ros',
category: 'security',
label: 'ROS-analyse (NS 5814 / ISO 31000)',
description: 'Risiko- og sårbarhetsanalyse med 7 dimensjoner og 49-trussel-bibliotek.',
argument_hint: '[system-beskrivelse] [--quick]',
calls_agent: 'ros-analysis-agent',
kb_files: ['ros-ai-threat-library.md', 'ros-scoring-rubrics-7x5.md', 'ros-methodology-ns5814-iso31000.md'],
produces_report: true,
report_archetype: 'matrix-risk',
report_root_class: 'matrix',
renderer: 'renderRos',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'system_name', label: 'Systemnavn', type: 'text', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'complexity', label: 'Kompleksitet', type: 'select', from: 'local', options: ['ENKEL', 'MIDDELS', 'KOMPLEKS'] },
{ id: 'quick_mode', label: 'Hurtig-modus (mal A)', type: 'boolean', from: 'local' }
]
},
{
id: 'review',
category: 'security',
label: 'Arkitekturgjennomgang',
description: 'Gjennomgang mot Digdir, AI Act, NSM, Schrems II og norsk offentlig sektor-krav.',
argument_hint: '[arkitektur el. kontekst]',
calls_agent: 'architecture-review-agent',
kb_files: ['decision-trees.md', 'security.md', 'public-sector-checklist.md'],
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderReview',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'architecture_description', label: 'Arkitekturbeskrivelse', type: 'textarea', from: 'local' },
{ id: 'review_stage', label: 'Stadium', type: 'select', from: 'local', options: ['Pre-implementering', 'POC', 'Produksjon'] }
]
},
// ===== ECONOMY (2) =====
{
id: 'cost',
category: 'economy',
label: 'Kostnadsestimat (P10/P50/P90 NOK)',
description: 'Kostnadsestimering med konfidensgradering og TCO-sammenligning.',
argument_hint: '[plattform] med [antall brukere], [volum/dag]',
calls_agent: 'cost-estimation-agent',
kb_files: ['deterministic-cost-calculation-model.md', 'azure-ai-foundry-cost-governance.md', 'cost-models.md'],
produces_report: true,
report_archetype: 'cost-distribution',
report_root_class: 'distribution',
renderer: 'renderCost',
input_fields: [
SHARED.organisation_name,
SHARED.license_type,
SHARED.cloud_platform,
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'users', label: 'Antall brukere', type: 'number', from: 'local' },
{ id: 'volume_per_day', label: 'Volum per dag (transaksjoner/forespørsler)', type: 'text', from: 'local' },
{ id: 'region', label: 'Region', type: 'select', from: 'local', options: ['Norge (Norway East/West)', 'EU/EØS', 'Globalt'] }
]
},
{
id: 'license',
category: 'economy',
label: 'Lisens → AI-kapabiliteter',
description: 'Map lisenstype mot inkluderte AI-kapabiliteter og identifiser gap.',
argument_hint: '[lisenstype]',
calls_agent: 'license-mapper-agent',
kb_files: ['licensing-matrix.md'],
produces_report: true,
report_archetype: 'capability',
report_root_class: 'capability-matrix',
renderer: 'renderLicense',
input_fields: [
SHARED.organisation_name,
SHARED.license_type,
SHARED.ai_services_in_use,
{ id: 'license_types', label: 'Lisenser å vurdere', type: 'multiSelect', from: 'local', options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Copilot for M365', 'Power Platform Premium'] }
]
},
// ===== DOCUMENTATION (6) =====
{
id: 'migrate',
category: 'documentation',
label: 'Migreringsplan',
description: 'Plan for migrasjon mellom Microsoft AI-plattformer.',
argument_hint: 'fra [kilde] til [mål]',
calls_agent: null,
kb_files: ['migration-patterns.md'],
produces_report: true,
report_archetype: 'phased-plan',
report_root_class: 'aiact-timeline',
renderer: 'renderMigrate',
input_fields: [
SHARED.organisation_name,
{ id: 'source_platform', label: 'Fra (kildeplattform)', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'target_platform', label: 'Til (målplattform)', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' },
{ id: 'timeline_weeks', label: 'Tidslinje (uker)', type: 'number', from: 'local' }
]
},
{
id: 'adr',
category: 'documentation',
label: 'ADR (MADR v3.0)',
description: 'Architecture Decision Record i MADR v3.0-format.',
argument_hint: '[valgfritt: tittel]',
calls_agent: 'adr-writer-agent',
kb_files: ['adr-template.md'],
produces_report: true,
report_archetype: 'markdown',
report_root_class: 'markdown-fallback',
renderer: 'renderAdr',
input_fields: [
SHARED.organisation_name,
{ id: 'decision_title', label: 'Beslutningstittel', type: 'text', from: 'local' },
{ id: 'decision_context', label: 'Kontekst', type: 'textarea', from: 'local' },
{ id: 'alternatives', label: 'Alternativer vurdert', type: 'textarea', from: 'local' },
{ id: 'decision', label: 'Valgt løsning', type: 'textarea', from: 'local' }
]
},
{
id: 'summary',
category: 'documentation',
label: 'Teknisk sammendrag + beslutningsnotat',
description: 'Aggregerer .work/-rapporter til teknisk sammendrag og beslutningsnotat.',
argument_hint: '[løsningsnavn]',
calls_agent: 'summary-agent',
kb_files: [],
produces_report: true,
report_archetype: 'verdict',
report_root_class: 'verdict-block',
renderer: 'renderSummary',
input_fields: [
SHARED.organisation_name,
{ id: 'solution_name', label: 'Løsningsnavn', type: 'text', from: 'local' }
]
},
{
id: 'poc',
category: 'documentation',
label: 'POC-plan',
description: 'POC-plan med suksesskriterier, tidslinje, risiko og Go/No-Go.',
argument_hint: '[plattform] for [use case]',
calls_agent: null,
kb_files: ['poc-template.md'],
produces_report: true,
report_archetype: 'phased-plan',
report_root_class: 'pipeline-cockpit',
renderer: 'renderPoc',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
SHARED.annual_ai_budget,
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'use_case', label: 'Use case', type: 'textarea', from: 'local' },
{ id: 'team_size', label: 'Team-størrelse', type: 'number', from: 'local' },
{ id: 'team_level', label: 'Team-nivå', type: 'select', from: 'local', options: ['Junior', 'Mid', 'Senior', 'Mixed'] },
{ id: 'timeline_weeks', label: 'Tidslinje (uker)', type: 'number', from: 'local' },
{ id: 'stakeholders', label: 'Interessenter', type: 'textarea', from: 'local' }
]
},
{
id: 'utredning',
category: 'documentation',
label: 'AI-arkitekturutredning (off. sektor)',
description: 'Full S0S9 arkitekturutredning for norsk offentlig sektor.',
argument_hint: '[scenario]',
calls_agent: null,
kb_files: ['ai-utredning-template.md'],
produces_report: true,
report_archetype: 'markdown',
report_root_class: 'markdown-fallback',
renderer: 'renderUtredning',
input_fields: [
SHARED.organisation_name,
SHARED.sector,
SHARED.regulatory_requirements,
{ id: 'scenario_name', label: 'Scenario-navn', type: 'text', from: 'local' },
{ id: 'scenario_description', label: 'Scenario-beskrivelse', type: 'textarea', from: 'local' },
{ id: 'system_description', label: 'Systembeskrivelse', type: 'textarea', from: 'local' }
]
},
{
id: 'compare',
category: 'documentation',
label: 'Sammenlign plattformer',
description: 'Side-by-side sammenligning av Microsoft AI-plattformer for et use case.',
argument_hint: '[plattform A] vs [plattform B] for [use case]',
calls_agent: 'research-agent',
kb_files: ['decision-trees.md'],
produces_report: true,
report_archetype: 'comparison',
report_root_class: 'diff',
renderer: 'renderCompare',
input_fields: [
SHARED.organisation_name,
{ id: 'platform_a', label: 'Plattform A', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'platform_b', label: 'Plattform B', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'use_case', label: 'Use case', type: 'textarea', from: 'local' }
]
},
// ===== TOOL (7) — ingen rapport, kun skjema + output-kopiering =====
{
id: 'architect',
category: 'tool',
label: 'Start Cosmo-rådgivning',
description: 'Start strukturert AI-arkitekturrådgivning med Cosmo Skyberg-persona.',
argument_hint: '[beskriv ditt forretningsproblem]',
calls_agent: null,
kb_files: [],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
SHARED.organisation_name,
SHARED.sector,
{ id: 'business_problem', label: 'Forretningsproblem', type: 'textarea', from: 'local' }
]
},
{
id: 'help',
category: 'tool',
label: 'Hjelp',
description: 'Vis kommando-/agent-/KB-oversikt eller detaljer for et emne.',
argument_hint: '[emne for detaljer]',
calls_agent: null,
kb_files: [],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'topic', label: 'Emne (valgfritt)', type: 'text', from: 'local' }
]
},
{
id: 'research',
category: 'tool',
label: 'Plattform-research',
description: 'Siste-nytt-research for en Microsoft AI-plattform.',
argument_hint: '[plattformnavn] [tidsperiode]',
calls_agent: 'research-agent',
kb_files: [],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'platform', label: 'Plattform', type: 'select', from: 'local', options: PLATFORMS },
{ id: 'time_period', label: 'Tidsperiode', type: 'select', from: 'local', options: ['siste uke', 'siste måned', 'siste kvartal', 'siste år'] }
]
},
{
id: 'diagram',
category: 'tool',
label: 'Generer arkitekturdiagram',
description: 'Generer arkitekturdiagram med Imagen 3 (mcp-image).',
argument_hint: '[type] for [scenario]',
calls_agent: 'diagram-generation-agent',
kb_files: ['diagram-prompt-templates.md'],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'diagram_type', label: 'Diagramtype', type: 'select', from: 'local', options: ['arkitektur', 'sikkerhet', 'dataflyt', 'problem', 'roadmap'] },
{ id: 'scenario', label: 'Scenario', type: 'text', from: 'local' },
{ id: 'component_list', label: 'Komponenter (valgfritt)', type: 'textarea', from: 'local' }
]
},
{
id: 'onboard',
category: 'tool',
label: 'Onboard plugin',
description: 'Onboard pluginen med virksomhetsspesifikk kontekst (5-fase intervju).',
argument_hint: '[--status]',
calls_agent: 'onboarding-agent',
kb_files: [],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'status_only', label: 'Bare vis status', type: 'boolean', from: 'local' }
]
},
{
id: 'generate-skills',
category: 'tool',
label: 'Generer KB-filer (batch)',
description: 'Generer kunnskapsfiler med MCP-research i batch.',
argument_hint: '[antall]',
calls_agent: null,
kb_files: [],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'count', label: 'Antall filer å generere', type: 'number', from: 'local' }
]
},
{
id: 'export',
category: 'tool',
label: 'Eksporter til PDF',
description: 'Eksporter et arkitekturdokument til PDF.',
argument_hint: '[filsti til markdown]',
calls_agent: null,
kb_files: [],
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'file_path', label: 'Filsti til markdown', type: 'text', from: 'local' }
]
}
]
};
// Eksponer for Step 5/8/9/11/12 og DevTools.
window.__CATALOG = CATALOG;
window.__SHARED_FIELDS = SHARED;
window.__FIELD_TYPES = FIELD_TYPES;
// ============================================================
// DOM HELPERS
// ============================================================
function escapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ============================================================
// COMMAND FORM RENDERER + __buildCommand (Step 8)
// ============================================================
//
// renderCommandForm(commandId, opts) genererer HTML for ett command-skjema
// basert på CATALOG[id].input_fields. Brukes både i prosjekt-detalj
// (Step 7 form-zone) og i katalog-modal (Step 9). Felter med from='shared'
// pre-fylles fra state.shared via field.shared_path; lokale felter
// pre-fylles fra project.reports[id].input når opts.projectId er gitt.
//
// window.__buildCommand(commandId, formData) bygger '/architect:<id>
// key="value" ...'-streng. Shared-felter merges inn først, formData
// overstyrer hvis samme nøkkel. Tomme/null-verdier hoppes over. formData
// kan inneholde nøkler som ikke finnes i CATALOG (passthrough).
function resolveSharedPath(path) {
if (!path || !store || !store.state || !store.state.shared) return undefined;
const parts = String(path).split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length; i++) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = cur[parts[i]];
}
return cur;
}
function isFilledArg(v, type) {
if (v == null) return false;
if (type === 'multiSelect' || Array.isArray(v)) return Array.isArray(v) && v.length > 0;
if (type === 'boolean' || typeof v === 'boolean') return v === true;
if (type === 'number' || typeof v === 'number') return !isNaN(v);
return String(v).trim() !== '';
}
function serializeArgValue(v) {
if (Array.isArray(v)) {
return '"' + v.map(function (x) { return String(x).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }).join(',') + '"';
}
if (typeof v === 'boolean') return String(v);
if (typeof v === 'number') return String(v);
const s = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return '"' + s + '"';
}
function buildCommand(commandId, formData) {
formData = formData || {};
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
const args = {};
// 1. Pre-fyll fra shared (CATALOG-definerte felles felter).
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (f.from === 'shared' && f.shared_path) {
const v = resolveSharedPath(f.shared_path);
if (isFilledArg(v, f.type)) args[f.id] = v;
}
});
}
// 2. formData overstyrer / utvider. Tillater nøkler som ikke er i CATALOG.
Object.keys(formData).forEach(function (k) {
const v = formData[k];
if (isFilledArg(v)) args[k] = v;
else delete args[k];
});
// 3. Bygg streng. Stable order: shared-felter først (i CATALOG-rekkefølge),
// så resten i insertion-order.
const orderedKeys = [];
const seen = {};
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (Object.prototype.hasOwnProperty.call(args, f.id) && !seen[f.id]) {
orderedKeys.push(f.id);
seen[f.id] = true;
}
});
}
Object.keys(args).forEach(function (k) {
if (!seen[k]) {
orderedKeys.push(k);
seen[k] = true;
}
});
const parts = ['/architect:' + commandId];
orderedKeys.forEach(function (k) {
parts.push(k + '=' + serializeArgValue(args[k]));
});
return parts.join(' ');
}
function renderCommandFormField(field, domId, value) {
const fromAttr = field.from === 'shared' ? 'shared' : 'local';
const dataAttrs = 'data-cf-field="' + escapeAttr(field.id) + '" data-cf-from="' + fromAttr + '" data-cf-type="' + escapeAttr(field.type) + '"';
const fromTag = field.from === 'shared'
? '<span class="field-from-tag" title="Forhåndsutfylt fra onboarding (state.shared.' + escapeAttr(field.shared_path || '') + ')">felles</span>'
: '';
const labelHtml = '<label for="' + domId + '" class="field-label">' + escapeHtml(field.label) + fromTag + '</label>';
let inputHtml = '';
if (field.type === 'text') {
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
} else if (field.type === 'textarea') {
inputHtml = '<textarea id="' + domId + '" ' + dataAttrs + ' class="textarea" rows="3">' + escapeHtml(value == null ? '' : String(value)) + '</textarea>';
} else if (field.type === 'number') {
inputHtml = '<input type="number" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null || value === '' ? '' : String(value)) + '" class="input">';
} else if (field.type === 'select') {
const opts = ['<option value="">(velg)</option>'].concat((field.options || []).map(function (o) {
const sel = (o === value) ? ' selected' : '';
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
})).join('');
inputHtml = '<select id="' + domId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
} 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 = domId + '-' + i;
return (
'<label class="checkbox-row" for="' + cbId + '">' +
'<input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-cf-multi="' + escapeAttr(o) + '"' + checked + '>' +
'<span>' + escapeHtml(o) + '</span>' +
'</label>'
);
}).join('');
inputHtml = (
'<fieldset class="multi-select" aria-labelledby="' + domId + '-legend">' +
'<legend id="' + domId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' +
opts +
'</fieldset>'
);
} else if (field.type === 'boolean') {
const checked = value === true ? ' checked' : '';
inputHtml = (
'<label class="checkbox-row" for="' + domId + '">' +
'<input type="checkbox" id="' + domId + '" ' + dataAttrs + checked + '>' +
'<span>Ja</span>' +
'</label>'
);
} else {
// Ukjent type — fall tilbake til text.
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
}
return '<div class="field-row" data-cf-field-row="' + escapeAttr(field.id) + '">' + labelHtml + inputHtml + '</div>';
}
function renderCommandForm(commandId, opts) {
opts = opts || {};
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
if (!cmd) {
return '<div class="guide-panel guide-panel--warn"><div class="guide-panel__icon" aria-hidden="true">!</div><div class="guide-panel__body"><p class="guide-panel__text">Ukjent command: ' + escapeHtml(commandId) + '</p></div></div>';
}
const project = opts.projectId ? findProject(opts.projectId) : null;
const savedInput = (project && project.reports && project.reports[commandId] && project.reports[commandId].input) || {};
const scope = opts.scope || 'p';
const fieldRows = (cmd.input_fields || []).map(function (f) {
const domId = 'cf-' + scope + '-' + cmd.id + '-' + f.id;
let value;
if (f.from === 'shared' && f.shared_path) {
value = resolveSharedPath(f.shared_path);
}
if (value === undefined || value === null || value === '') {
if (Object.prototype.hasOwnProperty.call(savedInput, f.id)) value = savedInput[f.id];
}
return renderCommandFormField(f, domId, value);
}).join('');
const sharedCount = (cmd.input_fields || []).filter(function (f) { return f.from === 'shared'; }).length;
const fieldCount = (cmd.input_fields || []).length;
return (
'<form class="command-form" data-command-form="' + escapeAttr(cmd.id) + '" data-command-form-scope="' + escapeAttr(scope) + '" autocomplete="off" onsubmit="return false;">' +
'<div class="command-form__fields">' + fieldRows + '</div>' +
'<div class="command-form__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="copy-command" data-command="' + escapeAttr(cmd.id) + '">Kopier kommando</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="preview-command" data-command="' + escapeAttr(cmd.id) + '">Forhåndsvis</button>' +
'<span class="command-form__hint">' + fieldCount + ' felter (' + sharedCount + ' fra shared).</span>' +
'<span class="command-form__copy-confirm" data-copy-confirm hidden></span>' +
'</div>' +
'<div class="form-preview" data-form-preview hidden>' +
'<h5 class="form-preview__heading">Pipeline-streng</h5>' +
'<pre class="code-block" data-form-preview-text></pre>' +
'</div>' +
'</form>'
);
}
function readCommandFormValues(formEl) {
const data = {};
if (!formEl) return data;
const cmdId = formEl.dataset.commandForm;
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
// Initialiser multiSelect til [] så uavkryssede ender opp tomme.
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (f.type === 'multiSelect') data[f.id] = [];
});
}
const inputs = formEl.querySelectorAll('[data-cf-field]');
for (let i = 0; i < inputs.length; i++) {
const el = inputs[i];
const id = el.dataset.cfField;
if (el.matches('input[type="checkbox"][data-cf-multi]')) {
if (el.checked) {
if (!Array.isArray(data[id])) data[id] = [];
data[id].push(el.dataset.cfMulti);
}
} else if (el.matches('input[type="checkbox"]')) {
data[id] = el.checked;
} else if (el.matches('input[type="number"]')) {
if (el.value === '' || el.value == null) {
data[id] = null;
} else {
const n = Number(el.value);
data[id] = isNaN(n) ? null : n;
}
} else {
data[id] = el.value;
}
}
return data;
}
function showCommandPreview(formEl, str) {
if (!formEl) return;
const box = formEl.querySelector('[data-form-preview]');
const text = formEl.querySelector('[data-form-preview-text]');
if (!box || !text) return;
text.textContent = str;
box.hidden = false;
}
function flashCopyConfirm(formEl, message) {
if (!formEl) return;
const tag = formEl.querySelector('[data-copy-confirm]');
if (!tag) return;
tag.textContent = message || 'Kopiert til utklippstavle.';
tag.hidden = false;
clearTimeout(tag.__hideTimer);
tag.__hideTimer = setTimeout(function () { tag.hidden = true; }, 2400);
}
// ============================================================
// SURFACE ROUTING (Step 5)
// ============================================================
//
// Én [data-surface] er synlig om gangen, drevet av state.activeSurface.
// navigate(name) muterer state og scheduler render. scheduleRender batcher
// via queueMicrotask så flere mutasjoner i samme tick gir én render.
//
// Vi subscriber IKKE alle state-endringer til render — det ville
// re-rendret skjemaer mens brukeren skriver. Render trigges eksplisitt
// fra action-handlers og navigate().
function getSurfaceEl(name) {
return document.querySelector('[data-surface="' + name + '"]');
}
function showSurface(name) {
const surfaces = document.querySelectorAll('main#app > [data-surface]');
for (let i = 0; i < surfaces.length; i++) {
surfaces[i].hidden = (surfaces[i].dataset.surface !== name);
}
}
let renderQueued = false;
function scheduleRender() {
if (renderQueued) return;
renderQueued = true;
queueMicrotask(function () {
renderQueued = false;
renderActive();
});
}
function renderActive() {
if (!store) return;
const active = store.state.activeSurface || 'home';
showSurface(active);
if (active === 'onboarding') renderOnboardingSurface();
else if (active === 'home') renderHomeSurface();
else if (active === 'project') renderProjectSurface();
else if (active === 'catalog') renderCatalogSurface();
}
function navigate(surface) {
store.state.activeSurface = surface;
scheduleRender();
}
// App-header — gjenbrukes på home, catalog, project. Onboarding viser ingen header
// (full-fokus førstegangs-flyt). Eksport/import-knapper wires opp til
// __exportState/__importState fra Step 3.
function renderTopbar(crumb) {
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const breadcrumbInner = (orgName ? escapeHtml(orgName) : '') + (orgName && crumb ? ' · ' : '') + (crumb || '');
const breadcrumbHtml = breadcrumbInner
? '<nav class="app-header__breadcrumb" aria-label="Brødsmuler">' + breadcrumbInner + '</nav>'
: '';
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const themeLabel = currentTheme === 'light' ? 'Lys' : 'Mørk';
const themeNext = currentTheme === 'light' ? 'mørk' : 'lys';
return (
'<header class="app-header">' +
'<div class="app-header__brand">' +
'<span class="app-header__brand-mark" aria-hidden="true">M</span>' +
'<span>ms-ai-architect</span>' +
'</div>' +
breadcrumbHtml +
'<div class="app-header__spacer"></div>' +
'<div class="app-header__actions" role="group" aria-label="Hovednavigasjon">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">Hjem</button>' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-catalog">Katalog</button>' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding">Re-onboard</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="export-state" aria-label="Eksporter state til JSON">Eksporter</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="import-state" aria-label="Importer state fra JSON">Importer</button>' +
'<input type="file" accept="application/json,.json" data-import-input hidden>' +
'<button type="button" class="theme-toggle" data-action="toggle-theme" aria-label="Bytt til ' + themeNext + ' modus">' +
'<span data-theme-label>' + themeLabel + '</span>' +
'</button>' +
'</div>' +
'</header>'
);
}
// ============================================================
// HOME SURFACE (Step 6)
// ============================================================
//
// 3 entry-tracks (.tracks med .tracks__card--guided/explore/expert) som
// første-valg på home. Under: prosjekt-liste i .fleet-grid med .fleet-tile
// per prosjekt. Tom-state: .guide-panel--info. "Nytt prosjekt"-knapp
// åpner modal (modal-handler i Step 7 — Step 6 har stub).
function projectReportCount(p) {
if (!p || !p.reports) return 0;
let count = 0;
for (const k in p.reports) {
if (p.reports[k] && p.reports[k].parsed) count++;
}
return count;
}
// Aggregert verdict for project-surface page-shell. Henter parsed.verdict
// fra alle reports og kollapser til én pille: block > go-with-conditions
// > approved > n-a. Tom reports{} -> 'n-a' per Sesjon 2-risk-flagg.
function inferProjectVerdict(project) {
const reports = (project && project.reports) || {};
const verdicts = [];
for (const k in reports) {
const v = reports[k] && reports[k].parsed && reports[k].parsed.verdict;
if (v) verdicts.push(String(v).toLowerCase());
}
if (verdicts.length === 0) return 'n-a';
for (let i = 0; i < verdicts.length; i++) {
if (verdicts[i] === 'block' || verdicts[i] === 'failed') return 'block';
}
for (let i = 0; i < verdicts.length; i++) {
const v = verdicts[i];
if (v === 'go-with-conditions' || v === 'warning') return 'go-with-conditions';
}
let allGo = true;
for (let i = 0; i < verdicts.length; i++) {
const v = verdicts[i];
if (v !== 'go' && v !== 'approved' && v !== 'allow') { allGo = false; break; }
}
return allGo ? 'approved' : 'n-a';
}
function inferProjectLastUpdated(project) {
const reports = (project && project.reports) || {};
let latest = null;
for (const k in reports) {
const r = reports[k];
if (r && r.updatedAt) {
if (!latest || r.updatedAt > latest) latest = r.updatedAt;
}
}
const ts = latest || (project && project.createdAt) || '';
return ts ? String(ts).slice(0, 10) : '';
}
function projectMeterBand(filled, total) {
if (total === 0) return '4'; // tom = "krever oppmerksomhet"
const pct = filled / total;
if (pct >= 0.8) return '1';
if (pct >= 0.5) return '2';
if (pct >= 0.2) return '3';
return '4';
}
function renderHomeSurface() {
const root = getSurfaceEl('home');
if (!root) return;
const projects = store.state.projects || [];
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
const tracksHtml = (
'<div class="tracks">' +
'<button type="button" class="tracks__card tracks__card--guided" data-action="goto-onboarding">' +
'<span class="tracks__card-icon" aria-hidden="true">⚙︎</span>' +
'<h3 class="tracks__card-title">Onboard / Re-onboard</h3>' +
'<p class="tracks__card-desc">Oppdater de 20 felles feltene som forhåndsutfyller alle command-skjemaer.</p>' +
'<span class="tracks__card-meta"><span>Felles state</span><span class="tracks__card-cta">Åpne →</span></span>' +
'</button>' +
'<button type="button" class="tracks__card tracks__card--explore" data-action="new-project">' +
'<span class="tracks__card-icon" aria-hidden="true"></span>' +
'<h3 class="tracks__card-title">Nytt prosjekt</h3>' +
'<p class="tracks__card-desc">Start et nytt arkitektur-prosjekt. Hvert prosjekt holder sine egne ROS, DPIA, AI Act-klassifisering osv.</p>' +
'<span class="tracks__card-meta"><span>Per-prosjekt state</span><span class="tracks__card-cta">Opprett →</span></span>' +
'</button>' +
'<button type="button" class="tracks__card tracks__card--expert" data-action="goto-catalog">' +
'<span class="tracks__card-icon" aria-hidden="true">◇</span>' +
'<h3 class="tracks__card-title">Command-katalog</h3>' +
'<p class="tracks__card-desc">Bla i alle 24 commands gruppert på kategori. Generer pipeline-strenger uten et prosjekt.</p>' +
'<span class="tracks__card-meta"><span>' + CATALOG.commands.length + ' commands</span><span class="tracks__card-cta">Bla →</span></span>' +
'</button>' +
'</div>'
);
const projectListHtml = (function () {
if (projects.length === 0) {
return (
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Du har ingen prosjekter ennå</h3>' +
'<p class="guide-panel__text">Opprett ditt første for å starte ROS-, DPIA- og AI Act-arbeid. Felles felter du fylte ut i onboarding gjenbrukes automatisk.</p>' +
'<div class="guide-panel__action">' +
'<button type="button" class="btn btn--primary" data-action="new-project">Opprett første prosjekt</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
const tiles = projects.map(function (p) {
const filled = projectReportCount(p);
const band = projectMeterBand(filled, reportTotal);
const pct = reportTotal ? Math.round(100 * filled / reportTotal) : 0;
const scenarios = Array.isArray(p.scenarios) ? p.scenarios : [];
const chip = scenarios.length > 0
? '<span class="fleet-tile__chip">' + escapeHtml(scenarios[0]) + (scenarios.length > 1 ? ' +' + (scenarios.length - 1) : '') + '</span>'
: '<span class="fleet-tile__chip">Uten scenario</span>';
return (
'<button type="button" class="fleet-tile" data-action="open-project" data-project-id="' + escapeAttr(p.id) + '">' +
'<div class="fleet-tile__row">' +
'<span class="fleet-tile__name" title="' + escapeAttr(p.name) + '">' + escapeHtml(p.name) + '</span>' +
chip +
'</div>' +
'<div class="fleet-tile__meter" aria-label="Rapport-fremdrift">' +
'<span class="fleet-tile__meter-fill" data-band="' + band + '" style="width:' + Math.max(pct, 4) + '%"></span>' +
'</div>' +
'<div class="fleet-tile__meta">' +
'<span>' + filled + '/' + reportTotal + ' rapporter</span>' +
'<span class="fleet-tile__trend--stable">' + pct + '%</span>' +
'</div>' +
'</button>'
);
}).join('');
return '<div class="fleet-grid">' + tiles + '</div>';
})();
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const activeReportCount = projects.reduce(function (a, p) { return a + projectReportCount(p); }, 0);
const homeShell = renderPageShell({
eyebrow: 'HJEM',
title: 'Hei, ' + (orgName || 'venn'),
lede: orgName
? 'Velg arbeidsspor eller utforsk eksisterende prosjekter. Felles state er aktiv og forhåndsutfyller skjemaer.'
: 'Single-file arkitektur-rådgivning for Microsoft AI-stakken. Start med onboarding for å aktivere felles state.',
verdict: 'n-a',
keyStats: [
{ label: 'PROSJEKTER', value: projects.length },
{ label: 'AKTIVE RAPPORTER', value: activeReportCount }
]
},
'<div class="stack-lg">' +
tracksHtml +
'<section class="home-projects">' +
'<span class="eyebrow">PROSJEKTER · ' + projects.length + ' av ' + projects.length + '</span>' +
'<div class="home-section-head">' +
'<h2>Mine prosjekter</h2>' +
'<span class="home-section-meta">' + projects.length + ' prosjekt' + (projects.length === 1 ? '' : 'er') + ' · maks ' + reportTotal + ' rapporter per prosjekt</span>' +
'</div>' +
projectListHtml +
(projects.length > 0 ? '<div class="onboarding-actions" style="margin-top: var(--space-4);"><button type="button" class="btn btn--primary" data-action="new-project">Nytt prosjekt</button></div>' : '') +
'</section>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Hjem') +
'<div class="app-shell">' +
homeShell +
'</div>'
);
}
// ============================================================
// PROJECT SURFACE (Step 7)
// ============================================================
//
// Per-prosjekt detalj: header med navn + scenario-chips, 5 kategori-tabs
// (én per CATALOG-kategori), command-kort i hver tab. Sub-zones per kort:
// 1. Skjema-zone — placeholder (Step 8 fyller med renderCommandForm)
// 2. Paste-import — KUN for produces_report=true (textarea + parse-knapp)
// 3. Rapport-slot — KUN for produces_report=true (data-report-slot)
// Verktøy-commands får skjema-zone + .guide-panel--info 'Verktøy'-notis.
//
// Prosjekt-opprettelse via modal (createProjectFromModal). projectId =
// crypto.randomUUID. Sletting via .error-summary-modal med eksplisitt
// bekreftelse.
//
// Active-tab er transient (modul-lokal currentProjectTab) så export-state
// ikke forurenses av UI-state. Default 'regulatory' ved hver project-enter.
// 8 scenarioer fra v2 — gjenbrukes som scenario-tags på prosjekter.
const SCENARIOS = [
{ id: 'rag-chatbot', name: 'RAG-chatbot for interne dokumenter' },
{ id: 'autonomous-agent', name: 'Autonom agent for saksbehandling' },
{ id: 'document-classification', name: 'Dokumentklassifisering og -prosessering' },
{ id: 'multi-agent', name: 'Multi-agent workflow' },
{ id: 'copilot-extension', name: 'Copilot-utvidelse for M365' },
{ id: 'customer-service', name: 'Kundeservice-chatbot' },
{ id: 'intelligent-search', name: 'Intelligent søk på tvers av fagsystemer' },
{ id: 'reporting', name: 'AI-assistert rapportering' }
];
let currentProjectTab = 'regulatory';
// Screen-tabs på project-surface (A4 Tier 3): Oversikt / Rapporter /
// Kontekst / Eksport. Default 'rapporter' = primær arbeidsflate (eksisterende
// category-tabs + panels). Andre skjermer er stub i Sesjon 2 og fylles ut
// i senere sesjoner.
let currentProjectScreen = 'rapporter';
function findProject(id) {
const list = store.state.projects || [];
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
function createProject(data) {
const id = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
const project = {
id: id,
name: data.name || 'Uten navn',
description: data.description || '',
scenarios: Array.isArray(data.scenarios) ? data.scenarios.slice() : [],
createdAt: new Date().toISOString(),
reports: {} // commandId → { input: {...}, raw_markdown: '', parsed: {...} }
};
// Push via Proxy så change-event fyres og persistens skedules.
store.state.projects.push(project);
store.state.activeProjectId = id;
currentProjectTab = 'regulatory';
currentProjectScreen = 'rapporter';
return project;
}
function deleteProject(id) {
const list = store.state.projects;
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) {
list.splice(i, 1);
break;
}
}
if (store.state.activeProjectId === id) store.state.activeProjectId = null;
}
// ---- Modal infrastructure ----
function mountModal(html) {
unmountModal();
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const node = wrapper.firstElementChild;
if (!node) return;
node.setAttribute('data-modal-root', 'true');
document.body.appendChild(node);
// Klikk på backdrop (selve roten) lukker; klikk inni .modal bobler ikke til root.
node.addEventListener('click', function (ev) {
if (ev.target === node) unmountModal();
});
// Esc lukker
function escHandler(ev) {
if (ev.key === 'Escape') {
unmountModal();
document.removeEventListener('keydown', escHandler);
}
}
document.addEventListener('keydown', escHandler);
// Fokuser første input
setTimeout(function () {
const first = node.querySelector('input, select, textarea, button');
if (first && first.focus) first.focus();
}, 0);
}
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 (
'<label class="checkbox-row" for="np-scen-' + i + '">' +
'<input type="checkbox" id="np-scen-' + i + '" data-new-project-scenario value="' + escapeAttr(s.id) + '">' +
'<span>' + escapeHtml(s.name) + '</span>' +
'</label>'
);
}).join('');
return (
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="np-title">' +
'<div class="modal">' +
'<h2 class="modal__title" id="np-title">Nytt prosjekt</h2>' +
'<div class="field-row">' +
'<label for="np-name" class="field-label">Prosjektnavn<span class="required-mark" aria-hidden="true">*</span></label>' +
'<input type="text" id="np-name" class="input" data-new-project-field="name" required>' +
'</div>' +
'<div class="field-row">' +
'<label for="np-desc" class="field-label">System-beskrivelse</label>' +
'<textarea id="np-desc" class="textarea" data-new-project-field="description" rows="3" placeholder="Hva skal AI-systemet gjøre? Hvilke brukere?"></textarea>' +
'</div>' +
'<div class="field-row">' +
'<span class="field-label">Scenario-tagging</span>' +
'<fieldset class="multi-select" aria-label="Scenarioer">' + scenarioOptions + '</fieldset>' +
'<span class="field-help">Brukes for sammenligning og pipeline-anbefalinger.</span>' +
'</div>' +
'<div class="error-summary" data-new-project-errors hidden role="alert">' +
'<h3 class="error-summary__heading">Mangler input</h3>' +
'<div class="error-summary__body"><p data-new-project-error-text></p></div>' +
'</div>' +
'<div class="modal__actions">' +
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Avbryt</button>' +
'<button type="button" class="btn btn--primary" data-action="create-project">Opprett</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
function renderDeleteProjectModalHtml(project) {
const reportCount = projectReportCount(project);
return (
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="dp-title">' +
'<div class="modal">' +
'<h2 class="modal__title" id="dp-title">Slett prosjekt?</h2>' +
'<div class="error-summary">' +
'<h3 class="error-summary__heading">Bekreft sletting</h3>' +
'<div class="error-summary__body">' +
'<p>Dette fjerner prosjektet <strong>' + escapeHtml(project.name) + '</strong> og ' + reportCount + ' importert' + (reportCount === 1 ? '' : 'e') + ' rapport' + (reportCount === 1 ? '' : 'er') + '. Handlingen kan ikke angres.</p>' +
'</div>' +
'</div>' +
'<div class="modal__actions">' +
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Avbryt</button>' +
'<button type="button" class="btn btn--destructive" data-action="confirm-delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett prosjekt</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
// ---- Sub-card rendering ----
function renderCommandSubCard(cmd, projectId) {
// Sev-modifier: hvis rapporten er parsed, mappe verdict til DS card--severity-{level}.
// Plugin-domain-verdicts (go/block/approved/...) → severity-band (positive/critical/medium).
const project = findProject(projectId);
const report = project && project.reports && project.reports[cmd.id];
const verdict = report && report.parsed && report.parsed.verdict
? String(report.parsed.verdict).toLowerCase() : '';
const sevMap = {
'go': 'positive', 'approved': 'positive', 'allow': 'positive',
'go-with-conditions': 'medium', 'warning': 'medium',
'block': 'critical', 'failed': 'critical'
};
const sevModifier = sevMap[verdict] || '';
const sevClass = sevModifier ? ' card--severity-' + sevModifier : '';
const titleHtml = (
'<div class="card__head">' +
'<div>' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
'<span class="card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
'</div>'
);
const formZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Skjema</h4>' +
'<div data-form-zone="' + escapeAttr(cmd.id) + '">' +
renderCommandForm(cmd.id, { context: 'project', projectId: projectId, scope: 'p' }) +
'</div>' +
'</div>'
);
if (!cmd.produces_report) {
// Verktøy: skjema-zone + .guide-panel--info notis
const toolNotice = (
'<div class="sub-zone">' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Verktøy</h3>' +
'<p class="guide-panel__text">Dette er et verktøy. Ingen rapport-import — bruk skjemaet til å bygge en pipeline-streng som kjøres i terminalen.</p>' +
'</div>' +
'</div>' +
'</div>'
);
return (
'<article class="card' + sevClass + '" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
titleHtml +
formZone +
toolNotice +
'</article>'
);
}
// Rapport-produserende: skjema-zone + paste-import-zone + report-zone
const pasteZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Lim inn rapport-output</h4>' +
'<div class="paste-import-row">' +
'<textarea class="textarea" data-paste-import="' + escapeAttr(cmd.id) + '" rows="4" placeholder="Lim inn markdown-output fra terminalen her"></textarea>' +
'<div class="paste-import-row__actions">' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="parse" data-command="' + escapeAttr(cmd.id) + '">Analyser rapport</button>' +
'<span class="field-help">Routes via PARSERS[' + escapeHtml(cmd.report_archetype || '?') + '] → ' + escapeHtml(cmd.renderer || '?') + ' (Step 11/12).</span>' +
'</div>' +
'</div>' +
'</div>'
);
const reportZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Visualisering</h4>' +
'<div class="report-slot ' + escapeAttr(cmd.report_root_class || '') + '" data-report-slot="' + escapeAttr(cmd.id) + '"></div>' +
'</div>'
);
return (
'<article class="card' + sevClass + '" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
titleHtml +
formZone +
pasteZone +
reportZone +
'</article>'
);
}
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);
// Action-bar (Tilbake / Slett) flyttet under page-shell-headeren —
// page__header har dedikert verdict-slot som ikke tar arbitrary HTML.
const actionBar = (
'<div class="onboarding-actions" style="justify-content: flex-end; margin-bottom: var(--space-4);">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">← Tilbake</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett</button>' +
'</div>'
);
// Tab-list (DS): Oversikt / Rapporter / Kontekst / Eksport.
// Sesjon 2: Rapporter er primær; andre er stub-skjermer som fylles ut
// i Sesjon 3-6.
const SCREENS = [
{ id: 'oversikt', label: 'Oversikt' },
{ id: 'rapporter', label: 'Rapporter' },
{ id: 'kontekst', label: 'Kontekst' },
{ id: 'eksport', label: 'Eksport' }
];
const screenTabsHtml = '<nav class="tab-list" role="tablist" aria-label="Prosjekt-skjermer">' + SCREENS.map(function (s) {
const isActive = currentProjectScreen === s.id;
return (
'<button type="button" class="tab" role="tab"' +
' aria-current="' + (isActive ? 'true' : 'false') + '"' +
' data-action="project-screen" data-screen="' + escapeAttr(s.id) + '">' +
escapeHtml(s.label) +
'</button>'
);
}).join('') + '</nav>';
// Tabs per CATALOG.categories — kun synlig under "Rapporter"-skjermen.
const tabsHtml = '<div class="project-tabs" role="tablist">' + CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
return (
'<button type="button" class="project-tab" role="tab"' +
(isActive ? ' aria-current="true"' : '') +
' data-action="project-tab" data-tab="' + escapeAttr(cat.id) + '">' +
escapeHtml(cat.label) +
'<span class="project-tab__count">' + cat.count + '</span>' +
'</button>'
);
}).join('') + '</div>';
// 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 (
'<div class="command-cards" role="tabpanel" data-tab-panel="' + escapeAttr(cat.id) + '"' + (isActive ? '' : ' hidden') + '>' +
cards +
'</div>'
);
}).join('');
const scenarioChipsList = (project.scenarios || []).map(function (sid) {
const s = SCENARIOS.find(function (x) { return x.id === sid; });
return '<li>' + escapeHtml(s ? s.name : sid) + '</li>';
}).join('');
const oversiktHtml = (
'<div class="tab-panel" data-screen-id="oversikt"' + (currentProjectScreen === 'oversikt' ? '' : ' hidden') + '>' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Oversikt</h3>' +
'<p class="guide-panel__text">Opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + '. ' + reportFilled + ' av ' + reportTotal + ' rapporter generert.</p>' +
(scenarioChipsList ? '<p class="guide-panel__text" style="margin-top: var(--space-2);"><strong>Scenarioer:</strong></p><ul style="margin: 0; padding-left: var(--space-4); color: var(--color-text-secondary);">' + scenarioChipsList + '</ul>' : '') +
'<p class="guide-panel__text" style="margin-top: var(--space-3);"><em>Sesjon 3+: aggregerte verdict-pillen, recommended-next-actions og top-risks vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const rapporterHtml = (
'<div class="tab-panel" data-screen-id="rapporter"' + (currentProjectScreen === 'rapporter' ? '' : ' hidden') + '>' +
tabsHtml +
panelsHtml +
'</div>'
);
const kontekstHtml = (
'<div class="tab-panel" data-screen-id="kontekst"' + (currentProjectScreen === 'kontekst' ? '' : ' hidden') + '>' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Kontekst</h3>' +
'<p class="guide-panel__text">Fellesfeltene fra onboarding gjenbrukes automatisk i alle command-skjemaer. Bruk <button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding" style="display:inline;">Re-onboard</button> for å oppdatere.</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);"><em>Sesjon 3+: snapshot av de 20 fellesfeltene og hva som er prefilled per command vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const eksportHtml = (
'<div class="tab-panel" data-screen-id="eksport"' + (currentProjectScreen === 'eksport' ? '' : ' hidden') + '>' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Eksport</h3>' +
'<p class="guide-panel__text">Bruk <strong>Eksporter</strong> i toppmenyen for hele state. Per-prosjekt eksport (PDF/Markdown) kommer i Sesjon 6.</p>' +
'</div>' +
'</div>' +
'</div>'
);
const projectShell = renderPageShell({
eyebrow: 'PROSJEKT',
title: project.name,
lede: project.description || '',
verdict: inferProjectVerdict(project),
keyStats: [
{ label: 'RAPPORTER', value: reportFilled + '/' + reportTotal },
{ label: 'SIST OPPDATERT', value: inferProjectLastUpdated(project) }
]
},
'<div class="stack-lg">' +
actionBar +
screenTabsHtml +
oversiktHtml +
rapporterHtml +
kontekstHtml +
eksportHtml +
'</div>'
);
root.innerHTML = (
renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
'<div class="app-shell app-shell--wide">' +
projectShell +
'</div>'
);
// v1.10.0+: rehydrer paste-imports etter at DOM er bygget. queueMicrotask
// sikrer at innerHTML har commit-et før vi spørr etter [data-paste-import].
queueMicrotask(rehydratePasteImports);
}
// ============================================================
// 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
? '<span class="card__pill">Verktøy</span>'
: '<span class="card__pill">Rapport</span>';
const hintHtml = cmd.argument_hint
? '<span class="card__hint">' + escapeHtml(cmd.argument_hint) + '</span>'
: '';
const verktoyNotice = isVerktoy
? '<div class="catalog-tool-notice">Verktøy — ingen rapport-import. Skjema bygger pipeline-streng som kjøres i terminalen.</div>'
: '';
return (
'<article class="card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
'<div class="card__head">' +
'<div>' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
pill +
'</div>' +
'<div class="card__meta">' +
'<span class="card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
hintHtml +
'</div>' +
verktoyNotice +
'<div class="card__actions">' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="open-catalog-form" data-command="' + escapeAttr(cmd.id) + '">Åpne skjema</button>' +
'</div>' +
'</article>'
);
}
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
? '<div class="catalog-cards">' + cardsHtml + '</div>'
: '<p class="command-form__hint" style="padding: var(--space-2) 0;">Ingen treff i denne kategorien.</p>';
return (
'<section class="expansion" aria-expanded="' + expanded + '" data-catalog-group="' + escapeAttr(cat.id) + '">' +
'<button type="button" class="expansion__head" data-action="catalog-toggle-group">' +
'<span class="expansion__title">' +
'<span class="expansion__title-main">' + escapeHtml(cat.label) + '</span>' +
'<span class="expansion__title-sub">' + subLabel + '</span>' +
'</span>' +
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="expansion__body">' +
'<div class="expansion__body-inner">' + body + '</div>' +
'</div>' +
'</section>'
);
}).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) + '»' : '');
const catalogShell = renderPageShell({
eyebrow: 'KATALOG',
title: 'Command-katalog',
lede: '24 kommandoer i 5 fagområder. Filtrer for å finne det du trenger, åpne skjema for å bygge en pipeline-streng.',
verdict: 'n-a',
keyStats: [
{ label: 'KOMMANDOER', value: 24 },
{ label: 'AGENTER', value: 12 },
{ label: 'SKILLS', value: 5 }
]
},
'<div class="stack-lg">' +
'<div class="catalog-toolbar">' +
'<input type="search" class="input" placeholder="Søk på navn, beskrivelse eller argument-hint…" value="' + escapeAttr(catalogSearchQuery) + '" data-catalog-search aria-label="Søk i katalog">' +
'<span class="catalog-toolbar__count" data-catalog-count>' + countText + '</span>' +
'</div>' +
'<div class="catalog-groups" data-catalog-groups>' + renderCatalogGroupsHtml() + '</div>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Katalog') +
'<div class="app-shell app-shell--wide">' +
catalogShell +
'</div>'
);
}
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
? (
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Verktøy</h3>' +
'<p class="guide-panel__text">Dette er et verktøy. Skjemaet bygger en pipeline-streng — ingen rapport-import.</p>' +
'</div>' +
'</div>'
)
: '';
return (
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="cf-modal-title">' +
'<div class="modal modal--wide">' +
'<div>' +
'<h2 class="modal__title" id="cf-modal-title">' + escapeHtml(cmd.label) + '</h2>' +
'<p class="card__desc" style="margin-top: var(--space-2);">' + escapeHtml(cmd.description) + '</p>' +
'<span class="card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
'</div>' +
verktoyBanner +
'<div>' + formHtml + '</div>' +
'<div class="modal__actions">' +
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Lukk</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
// ============================================================
// 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];
// Bucket-klassifisering — støtter bade engelske og norske status-markører.
const bucketOf = function (status) {
const s = (status || '').toLowerCase().trim();
if (/^(pass|met|ok|bestått|bestatt|godkjent|approved|done)$/.test(s)) return 'passed';
if (/^(partial|conditional|betinget|delvis|in-progress|active)$/.test(s)) return 'conditional';
if (/^(missing|failed|avvist|underkjent|fail|rejected|blocked)$/.test(s)) return 'failed';
return 'conditional';
};
const checklist = checklistTbl.rows.map(function (row) {
const status = (row[statusKey] || '').toLowerCase().trim();
return {
requirement: row[reqKey] || '',
status: status,
bucket: bucketOf(status),
evidence: row[evidKey] || ''
};
});
const buckets = { passed: [], conditional: [], failed: [] };
checklist.forEach(function (it) { buckets[it.bucket].push(it); });
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, buckets: buckets, 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;
// Restrisiko / residual-pair (Sesjon 3 — Dpia, men felt er optional og
// gjelder også fremtidig Ros-bruk per R15). Markdown-syntaks:
// Restrisiko: 4×3 → 2×2 (numerisk before/after med score)
// Restrisiko: medium → lav (label-only fallback)
let residualPair = null;
const rrMatch = md.match(/^Restrisiko\s*:\s*([^\n]+)$/im);
if (rrMatch) {
const txt = rrMatch[1];
const num = /(\d+)\s*[×x*]\s*(\d+)\s*(?:[-=]?[>→]|->)\s*(\d+)\s*[×x*]\s*(\d+)/.exec(txt);
if (num) {
const b1 = +num[1], b2 = +num[2], a1 = +num[3], a2 = +num[4];
residualPair = {
before: { prob: b1, cons: b2, score: b1 * b2 },
after: { prob: a1, cons: a2, score: a1 * a2 }
};
} else {
const parts = txt.split(/(?:[-=]?[>→]|->)/);
if (parts.length === 2) {
residualPair = {
before: { label: parts[0].trim() },
after: { label: parts[1].trim() }
};
}
}
}
// _consumer-diskriminator (R15): Settes til 'ros' når Ros-spesifikk
// markdown oppdages (## Top-risikoer eller ## Anbefaling). Dpia-fixturer
// har ingen av disse seksjonene → forblir null.
const hasTopRisks = /^##\s*Top.?risikoer\b/im.test(md);
const hasAnbefal = /^##\s*Anbefaling\b/im.test(md);
const consumer = (hasTopRisks || hasAnbefal) ? 'ros' : null;
// topRisks (R15, Ros-only): parse explicit ## Top-risikoer table, eller
// fallback til threats sortert på severity-rank (kritisk>høy>medium>lav).
// Felt er optional og brukes ikke av renderDpia. Tie-breaker: alfabetisk
// på description.
const sevRank = function (s) {
const v = String(s || '').toLowerCase();
if (/crit|kritisk/.test(v)) return 4;
if (/høy|high/.test(v)) return 3;
if (/medium|moderat/.test(v)) return 2;
if (/lav|low/.test(v)) return 1;
return 0;
};
let topRisks = [];
if (consumer === 'ros') {
const trTbl = parseTable(md, /##\s*Top.?risikoer/i);
if (trTbl && trTbl.rows.length) {
const idKey = trTbl.headers[0];
const descKey = trTbl.headers.find(function (h) { return /trussel|risiko|description|beskrivelse/i.test(h); }) || trTbl.headers[1];
const scKey = trTbl.headers.find(function (h) { return /score/i.test(h); });
const sevKey = trTbl.headers.find(function (h) { return /severity|alvorlighet|nivå/i.test(h); });
topRisks = trTbl.rows.map(function (row) {
return {
id: row[idKey] || '',
description: row[descKey] || row[idKey] || '',
score: scKey ? intOrZero(row[scKey] || '0') : 0,
severity: (sevKey && (row[sevKey] || '').toLowerCase().trim()) || ''
};
}).slice(0, 5);
} else if (threats.length) {
topRisks = threats.slice().sort(function (a, b) {
const r = sevRank(b.severity) - sevRank(a.severity);
if (r !== 0) return r;
return String(a.description || '').localeCompare(String(b.description || ''));
}).slice(0, 5).map(function (t) {
return { id: t.id, description: t.description, score: 0, severity: t.severity };
});
}
}
// recommendation (Ros-only): første avsnitt under ## Anbefaling.
let recommendation = '';
if (consumer === 'ros' && hasAnbefal) {
const m = md.match(/^##\s*Anbefaling\s*\n+([\s\S]*?)(?=\n##\s|\n$|$)/im);
if (m) recommendation = m[1].replace(/\n+$/, '').trim();
}
return { ok: true, data: {
matrix_cells: matrix_cells,
threats: threats,
radar_axes: radar_axes,
residualPair: residualPair,
topRisks: topRisks,
recommendation: recommendation,
_consumer: consumer
} };
}
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] || ''
};
}) : [];
// topRisks: prøv ## Top-risikoer-tabell først, ellers fall tilbake til
// matrix_cells sortert desc på score.
const topRisksTbl = parseTable(md, /##\s*Top.?risikoer/i);
let topRisks = [];
if (topRisksTbl && topRisksTbl.rows.length) {
const idKey = topRisksTbl.headers[0];
const descKey = topRisksTbl.headers.find(function (h) { return /risiko|trussel|description|beskrivelse/i.test(h); }) || topRisksTbl.headers[1];
const scKey = topRisksTbl.headers.find(function (h) { return /score/i.test(h); });
const sevKey = topRisksTbl.headers.find(function (h) { return /severity|alvorlighet|nivå/i.test(h); });
topRisks = topRisksTbl.rows.map(function (row) {
return {
id: row[idKey] || '',
description: row[descKey] || row[idKey] || '',
score: scKey ? intOrZero(row[scKey] || '0') : 0,
severity: (sevKey && (row[sevKey] || '').toLowerCase().trim()) || ''
};
}).slice(0, 5);
} else if (matrix_cells.length) {
topRisks = matrix_cells.slice().sort(function (a, b) {
if (b.score !== a.score) return b.score - a.score;
return String(a.label || '').localeCompare(String(b.label || ''));
}).slice(0, 5).map(function (c) {
return {
id: '',
description: c.label || '',
score: c.score,
severity: ''
};
});
}
// categoryGrades: prøv ## Kategori-snitt-tabell først, ellers utled
// fra dimensions[]. Score → letter-grade A-F (5→A, 4→B, 3→C, 2→D, ≤1→F).
const gradeFor = function (sc) {
const n = Number(sc) || 0;
if (n >= 5) return 'A';
if (n >= 4) return 'B';
if (n >= 3) return 'C';
if (n >= 2) return 'D';
return 'F';
};
const catTbl = parseTable(md, /##\s*Kategori.snitt/i);
let categoryGrades = [];
if (catTbl && catTbl.rows.length) {
const nameKey = catTbl.headers[0];
const scKey = catTbl.headers.find(function (h) { return /score|snitt/i.test(h); }) || catTbl.headers[1];
categoryGrades = catTbl.rows.map(function (row) {
const sc = intOrZero(row[scKey] || '0');
return { name: row[nameKey] || '', score: sc, grade: gradeFor(sc) };
});
} else {
categoryGrades = dimensions.map(function (d) {
return { name: d.name, score: d.score, grade: gradeFor(d.score) };
});
}
// residualPair: same syntax som parseMatrixRisk.
let residualPair = null;
const rrMatch = md.match(/^Restrisiko\s*:\s*([^\n]+)$/im);
if (rrMatch) {
const txt = rrMatch[1];
const num = /(\d+)\s*[×x*]\s*(\d+)\s*(?:[-=]?[>→]|->)\s*(\d+)\s*[×x*]\s*(\d+)/.exec(txt);
if (num) {
const b1 = +num[1], b2 = +num[2], a1 = +num[3], a2 = +num[4];
residualPair = {
before: { prob: b1, cons: b2, score: b1 * b2 },
after: { prob: a1, cons: a2, score: a1 * a2 }
};
} else {
const parts = txt.split(/(?:[-=]?[>→]|->)/);
if (parts.length === 2) {
residualPair = {
before: { label: parts[0].trim() },
after: { label: parts[1].trim() }
};
}
}
}
return {
ok: true,
data: {
dimensions: dimensions,
matrix_cells: matrix_cells,
findings: findings,
scores: dimensions.map(function (d) { return d.score; }),
topRisks: topRisks,
categoryGrades: categoryGrades,
residualPair: residualPair
}
};
}
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 stKey = tbl.headers.find(function (h) { return /^status$/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] || '',
status: stKey ? String(row[stKey] || '').toLowerCase().trim() : ''
};
});
// Bucket-mapping (E1 kanban + E6 suppressed-panel).
// Eksplisitt status-felt vinner. Fallback: severity-basert.
// suppressed/waived/ignored/akseptert → suppressed
// keep/behold/accepted → keep
// review/tilsyn/escalate/eskaler → review
// remove/fjern/reject/avvis/blokker → remove
// severity critical/kritisk/high/høy → review
// severity medium/moderat/low/lav → keep
const bucketOf = function (f) {
const s = f.status || '';
if (/suppress|waive|ignore|akseptert/.test(s)) return 'suppressed';
if (/^keep$|behold|accepted/.test(s)) return 'keep';
if (/^review$|tilsyn|escalat|eskaler/.test(s)) return 'review';
if (/^remove$|fjern|reject|avvis|blokk/.test(s)) return 'remove';
const sev = f.severity || '';
if (/crit|kritisk/.test(sev)) return 'review';
if (/høy|high/.test(sev)) return 'review';
if (/medium|moderat/.test(sev)) return 'keep';
if (/lav|low/.test(sev)) return 'keep';
return 'review';
};
const buckets = { keep: [], review: [], remove: [], suppressed: [] };
findings.forEach(function (f) { buckets[bucketOf(f)].push(f); });
return { ok: true, data: { findings: findings, buckets: buckets } };
}
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,
status: 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;
}
const statusMatch = /^Status\s*:\s*([\wæøåA-Za-z-]+)/i.exec(trimmed);
if (statusMatch) {
// Normaliser til en av: planned | active | done.
const raw = statusMatch[1].toLowerCase();
let s = null;
if (/^(done|ferdig|fullf[øo]rt|complete[d]?)$/.test(raw)) s = 'done';
else if (/^(active|aktiv|p[åa]g[åa]ende|igang|in[-_]?progress|current|n[åa])$/.test(raw)) s = 'active';
else if (/^(planned|planlagt|kommende|future|fremtid)$/.test(raw)) s = 'planned';
current.status = s || raw;
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);
// Utled currentPhaseIndex: første 'active' ELLER første ikke-'done'.
// R15: -1 hvis ingen faser har status (forward-compat — eksisterende fixtures uberørt).
let currentPhaseIndex = -1;
const anyStatus = phases.some(function (p) { return p.status; });
if (anyStatus) {
for (let i = 0; i < phases.length; i++) {
if (phases[i].status === 'active') { currentPhaseIndex = i; break; }
}
if (currentPhaseIndex < 0) {
for (let i = 0; i < phases.length; i++) {
if (phases[i].status !== 'done') { currentPhaseIndex = i; break; }
}
}
}
// POC-verdict (kun for poc-consumer): "## POC-Verdict: GO|BETINGET|BLOKK"
// R15: undefined for migrate-consumer (uberørt felt).
let pocVerdict;
const pvMatch = /^##\s*POC[- ]?Verdict\s*:\s*([A-Za-zØøÆæÅå -]+)$/im.exec(md);
if (pvMatch) {
const tag = pvMatch[1].toLowerCase().trim();
if (/^(go-?with-?conditions|betinget|conditions?|conditional)$/.test(tag)) pocVerdict = 'go-with-conditions';
else if (/^(block|blokk|blokkert|stop)$/.test(tag)) pocVerdict = 'block';
else if (/^(go|godkjent|ok|pass)$/.test(tag)) pocVerdict = 'go';
}
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)' }] };
const out = { phases: phases, risks: risks, currentPhaseIndex: currentPhaseIndex };
if (pocVerdict) out.pocVerdict = pocVerdict;
return { ok: true, data: out };
}
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] || '') : ''
};
});
// R15: optional winner-felt fra "## Vinner: <id>"-linje. Brukes av
// renderCompare for verdict-pill og scenario-card highlight.
const out = { subjects: subjects, rows: rows };
const winMatch = /^##\s*Vinner\s*:\s*(.+?)\s*$/im.exec(md) || /^Winner\s*:\s*(.+?)\s*$/im.exec(md);
if (winMatch) out.winner = winMatch[1].trim();
return { ok: true, data: out };
}
// ---- 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 '<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body"><p class="guide-panel__text">Ingen data å vise — tom eller ufullstendig parsing.</p></div>' +
'</div>';
}
function renderError(errors, slot) {
const items = (errors || []).map(function (e) {
return '<li><strong>' + escapeHtml(e.section || 'feil') + ':</strong> ' + escapeHtml(e.reason || 'Ukjent') + '</li>';
}).join('');
slot.innerHTML =
'<div class="error-summary" role="alert">' +
'<h3 class="error-summary__heading">Kunne ikke parse rapporten</h3>' +
'<div class="error-summary__body"><p>Justér markdown-format og lim inn på nytt.</p>' +
(items ? '<ul>' + items + '</ul>' : '') +
'</div>' +
'</div>';
}
function renderThreatsTable(threats) {
if (!threats || !threats.length) return '';
const rows = threats.map(function (t) {
return '<tr><td>' + escapeHtml(t.id || '') + '</td><td>' + escapeHtml(t.description || '') + '</td><td>' + escapeHtml(t.severity || '') + '</td><td>' + escapeHtml(t.mitigation || '') + '</td></tr>';
}).join('');
return '<table class="report-table"><thead><tr><th>ID</th><th>Beskrivelse</th><th>Severity</th><th>Tiltak</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
function renderFindingsBlock(findings, label) {
const items = findings.map(function (f) {
return '<li class="findings__item">' +
'<span class="findings__item-severity-dot" data-severity="' + escapeAttr(f.severity || 'info') + '"></span>' +
'<span class="findings__item-id">' + escapeHtml(f.id || '') + '</span>' +
'<span class="findings__item-title">' + escapeHtml(f.recommendation || '') + '</span>' +
'<span class="findings__item-meta">Lokasjon: ' + escapeHtml(f.location || '—') + ' · Severity: ' + escapeHtml(f.severity || '—') + '</span>' +
'</li>';
}).join('');
return '<div class="findings">' +
'<div class="findings__list">' +
'<div class="findings__group">' +
'<div class="findings__group-header"><span>' + escapeHtml(label) + '</span><span>' + findings.length + '</span></div>' +
'<ul class="findings__items">' + items + '</ul>' +
'</div>' +
'</div>' +
'</div>';
}
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 = '<div class="matrix"><div class="matrix__y-label">Konsekvens</div><div class="matrix__main">';
html += '<div class="matrix__grid" style="grid-template-rows: repeat(' + cons_max + ', 1fr) 32px;">';
for (let cons = cons_max; cons >= 1; cons--) {
html += '<div class="matrix__y-tick">' + cons + '</div>';
for (let prob = 1; prob <= probSize; prob++) {
const score = prob * cons;
const items = byPC[prob + '_' + cons] || [];
const bubblesHtml = items.length
? '<div class="matrix__cell-bubbles">' +
items.slice(0, 3).map(function (it, i) {
return '<span class="matrix__bubble" title="' + escapeAttr(it.label || '') + '">' + (i + 1) + '</span>';
}).join('') +
(items.length > 3 ? '<span class="matrix__bubble matrix__bubble--count">+' + (items.length - 3) + '</span>' : '') +
'</div>'
: '';
html += '<div class="matrix__cell" data-score="' + score + '">' +
'<span class="matrix__cell-score">' + score + '</span>' + bubblesHtml +
'</div>';
}
}
html += '<div class="matrix__corner"></div>';
for (let prob = 1; prob <= probSize; prob++) {
html += '<div class="matrix__x-tick">' + prob + '</div>';
}
html += '</div>';
html += '<div class="matrix__x-label">Sannsynlighet</div>';
html += '</div></div>';
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 '<text class="radar__label" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" text-anchor="middle" dominant-baseline="middle">' + escapeHtml(a.name) + '</text>';
}).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 '<line class="radar__axis" x1="' + cx + '" y1="' + cy + '" x2="' + x.toFixed(1) + '" y2="' + y.toFixed(1) + '"/>';
}).join('');
return '<div class="radar"><div class="radar__chart">' +
'<svg class="radar__svg" viewBox="0 0 300 300">' +
'<circle class="radar__grid-line" cx="' + cx + '" cy="' + cy + '" r="' + R + '" fill="none"/>' +
'<circle class="radar__grid-line" cx="' + cx + '" cy="' + cy + '" r="' + (R * 0.6) + '" fill="none"/>' +
spokes + labels +
'<polygon class="radar__series" points="' + points + '" fill="rgba(99,102,241,0.25)" stroke="currentColor" stroke-width="2"/>' +
'</svg>' +
'</div></div>';
}
// ---- 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)', desc: 'Forbudte AI-praksiser: subliminal manipulasjon, sosial scoring, manipulering av sårbare grupper, biometrisk fjernidentifisering i sanntid (med unntak).' },
{ id: 'high', label: 'Høyrisiko (Art. 6 + Annex III)', desc: 'Krever full compliance: risikostyringssystem, datakvalitet, teknisk dokumentasjon, transparens, menneskelig oversikt, robusthet og — for offentlig sektor — FRIA før idriftsettelse.' },
{ id: 'limited', label: 'Begrenset risiko (Art. 50)', desc: 'Transparenskrav: brukere skal informeres om at de samhandler med AI. Gjelder bl.a. chatboter, deepfakes og emosjonell gjenkjenning.' },
{ id: 'minimal', label: 'Minimal risiko', desc: 'Ingen særskilte krav under AI Act. Frivillige Codes of Conduct anbefales og dokumenteres som god praksis.' }
];
const tiersHtml = tiers.map(function (t) {
const active = (t.id === activeTier);
const ariaCurrent = active ? ' aria-current="true"' : '';
const marker = active ? ' <span class="pyramide__tier-share">← klassifisert</span>' : '';
return '<div class="pyramide__tier pyramide__tier--' + t.id + '"' + ariaCurrent + '>' +
'<div class="pyramide__tier-label">' + escapeHtml(t.label) + '</div>' +
marker +
'</div>';
}).join('');
const tierDescsHtml = '<div class="stack-sm" style="margin-top: var(--space-4);">' + tiers.map(function (t) {
const open = (t.id === activeTier) ? ' open' : '';
return '<details class="pyramide-tier-detail" data-tier="' + escapeAttr(t.id) + '"' + open + '>' +
'<summary>' + escapeHtml(t.label) + '</summary>' +
'<div class="pyramide-tier-detail__body">' + escapeHtml(t.desc) + '</div>' +
'</details>';
}).join('') + '</div>';
const obligationsHtml = (data.obligations || []).length
? '<section class="report-meta"><h4>Forpliktelser</h4><ul>' +
data.obligations.map(function (o) { return '<li>' + escapeHtml(o) + '</li>'; }).join('') +
'</ul></section>'
: '';
const meta = '<section class="report-meta"><dl>' +
'<dt>Rolle</dt><dd>' + escapeHtml(data.role || '—') + '</dd>' +
(data.reasoning ? '<dt>Begrunnelse</dt><dd>' + escapeHtml(data.reasoning).slice(0, 800) + '</dd>' : '') +
'</dl></section>';
const body = '<div class="pyramide">' + tiersHtml + '</div>' + tierDescsHtml + meta + obligationsHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'KLASSIFISERING',
title: data.title || 'EU AI Act-klassifisering',
lede: data.lede || 'Risikonivå, rolle og forpliktelser etter AI Act.',
verdict: data.verdict || inferVerdict(data, 'aiact'),
keyStats: data.keyStats || inferKeyStats(data, 'aiact')
}, body);
}
function renderRequirements(data, slot) {
const items = data.items || [];
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 dominantStatus = function (group) {
if (group.some(function (it) { return /missing/i.test(it.status); })) return 'missing';
if (group.some(function (it) { return /partial/i.test(it.status); })) return 'partial';
return 'met';
};
// Group by source_article (Art. X) for scenario-card-grid.
const groups = {};
items.forEach(function (it) {
const key = it.source_article || 'Andre';
if (!groups[key]) groups[key] = [];
groups[key].push(it);
});
const groupKeys = Object.keys(groups).sort();
const cardsHtml = groupKeys.length ? '<div class="scenario-card-grid">' + groupKeys.map(function (k) {
const group = groups[k];
const status = dominantStatus(group);
return '<div class="scenario-card" data-status="' + escapeAttr(status) + '">' +
'<div class="scenario-card__head">' +
'<span class="scenario-card__source">' + escapeHtml(k) + '</span>' +
'<span class="scenario-card__count">' + group.length + ' krav</span>' +
'</div>' +
'<p class="scenario-card__title">' + escapeHtml(group[0].requirement) + (group.length > 1 ? ' (+' + (group.length - 1) + ')' : '') + '</p>' +
'</div>';
}).join('') + '</div>' : '';
const expansionsHtml = items.length ? items.map(function (it, idx) {
const sev = sevForStatus(it.status);
return '<div class="expansion" aria-expanded="false">' +
'<button type="button" class="expansion__head" data-action="requirement-expand" data-idx="' + idx + '">' +
'<span class="findings__item-severity-dot" data-severity="' + escapeAttr(sev) + '"></span>' +
'<span class="expansion__title">' +
'<span class="expansion__title-main">R-' + String(idx + 1).padStart(2, '0') + ' — ' + escapeHtml(it.requirement) + '</span>' +
'<span class="expansion__title-sub">Kilde: ' + escapeHtml(it.source_article || '—') + ' · Status: ' + escapeHtml(it.status || '—') + '</span>' +
'</span>' +
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="expansion__body"><div class="expansion__body-inner"><div>' +
'<dl><dt>Kilde</dt><dd>' + escapeHtml(it.source_article || '—') + '</dd>' +
'<dt>Status</dt><dd>' + escapeHtml(it.status || '—') + '</dd></dl>' +
'</div></div></div>' +
'</div>';
}).join('') : '';
const body = cardsHtml + (expansionsHtml ? '<div class="findings">' + expansionsHtml + '</div>' : '');
slot.innerHTML = renderPageShell({
eyebrow: 'KRAV',
title: data.title || 'AI Act-krav per risiko og rolle',
lede: data.lede || 'Konkrete forpliktelser gruppert etter Art-paragraf med detaljer per krav.',
verdict: data.verdict || inferVerdict(data, 'requirements-list'),
keyStats: data.keyStats || inferKeyStats(data, 'requirements-list')
}, body);
}
function renderTransparency(data, slot) {
const READ_MORE_THRESHOLD = 240;
const sectionsHtml = (data.sections || []).map(function (s) {
const body = (s.body || '').trim();
if (body.length > READ_MORE_THRESHOLD) {
const head = body.slice(0, READ_MORE_THRESHOLD);
const rest = body.slice(READ_MORE_THRESHOLD);
return '<section><h2>' + escapeHtml(s.heading) + '</h2>' +
'<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
'<details class="read-more-block">' +
'<summary>Les hele klausulen</summary>' +
'<p>' + escapeHtml(rest).replace(/\n/g, '<br>') + '</p>' +
'</details>' +
'</section>';
}
return '<section><h2>' + escapeHtml(s.heading) + '</h2><p>' + escapeHtml(body).replace(/\n/g, '<br>') + '</p></section>';
}).join('');
const body = '<article class="report-doc">' + sectionsHtml + '</article>';
slot.innerHTML = renderPageShell({
eyebrow: 'ÅPENHET',
title: data.title || 'Transparensnotis',
lede: data.lede || 'Generert basert på EU AI Act Art. 13/50 og GDPR Art. 13/14.',
verdict: data.verdict || inferVerdict(data, 'text-document'),
keyStats: data.keyStats || inferKeyStats(data, 'text-document')
}, body);
}
function renderFria(data, slot) {
const sevForImpact = function (n) {
const v = Number(n) || 0;
if (v >= 4) return 'critical';
if (v >= 3) return 'high';
if (v >= 2) return 'medium';
if (v >= 1) return 'low';
return 'info';
};
const cardsHtml = (data.rights || []).map(function (r, idx) {
const sev = sevForImpact(r.impact);
return '<div class="critique-card" data-severity="' + escapeAttr(sev) + '">' +
'<div class="critique-card__header">' +
'<div class="critique-card__title">' + escapeHtml(r.name) + '</div>' +
'<div class="critique-card__meta">' +
'<span class="critique-card__id">FRIA-' + String(idx + 1).padStart(2, '0') + '</span>' +
'<span class="critique-card__id" style="background: var(--color-bg-soft);">Impact ' + escapeHtml(String(r.impact || 0)) + '/5</span>' +
'</div>' +
'</div>' +
(r.mitigation ? '<div class="critique-card__recommendation">' + escapeHtml(r.mitigation) + '</div>' : '') +
'</div>';
}).join('');
const body = '<div class="critique-cards">' + (cardsHtml || '<p class="muted">Ingen rettigheter registrert.</p>') + '</div>';
slot.innerHTML = renderPageShell({
eyebrow: 'FRIA',
title: data.title || 'Fundamental Rights Impact Assessment',
lede: data.lede || 'EU AI Act Art. 27 — obligatorisk for offentlig sektor som deployer.',
verdict: data.verdict || inferVerdict(data, 'fria'),
keyStats: data.keyStats || inferKeyStats(data, 'fria')
}, body);
}
function renderConformity(data, slot) {
const buckets = data.buckets || { passed: [], conditional: [], failed: [] };
const cardFor = function (bucket, label) {
const items = buckets[bucket] || [];
const cards = items.length ? items.map(function (it, idx) {
return '<div class="kanban-card">' +
'<div class="kanban-card__name">C-' + String(idx + 1).padStart(2, '0') + ' — ' + escapeHtml(it.requirement) + '</div>' +
'<div class="kanban-card__meta">Bevis: ' + escapeHtml(it.evidence || '—') + '</div>' +
'</div>';
}).join('') : '<div class="kanban-col__empty">Ingen krav</div>';
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
'<div class="kanban-col__head">' +
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
'<span class="kanban-col__count">' + items.length + '</span>' +
'</div>' +
cards +
'</div>';
};
const kanbanHtml = '<div class="kanban-board">' +
cardFor('passed', 'Bestått') +
cardFor('conditional', 'Med betingelser') +
cardFor('failed', 'Ikke bestått') +
'</div>';
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 '<div class="aiact-timeline__milestone" data-state="' + escapeAttr(stateOf(d.status)) + '" style="left: ' + left.toFixed(1) + '%">' +
'<div class="aiact-timeline__dot"></div>' +
'<div class="aiact-timeline__label">' +
'<span class="aiact-timeline__label-date">' + escapeHtml(d.date) + '</span>' +
'<span class="aiact-timeline__label-name">' + escapeHtml(d.milestone) + '</span>' +
'</div>' +
'</div>';
}).join('');
timelineHtml =
'<section class="report-meta"><h4>Frister</h4>' +
'<div class="aiact-timeline">' +
'<div class="aiact-timeline__track">' +
'<div class="aiact-timeline__progress" style="width: 0%"></div>' +
milestones +
'</div>' +
'</div>' +
'</section>';
}
const body = kanbanHtml + timelineHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'SAMSVAR',
title: data.title || 'Samsvarsvurdering (Art. 43)',
lede: data.lede || 'Annex IV-sjekkliste fordelt på Bestått / Med betingelser / Ikke bestått.',
verdict: data.verdict || inferVerdict(data, 'conformity-checklist'),
keyStats: data.keyStats || inferKeyStats(data, 'conformity-checklist')
}, body);
}
function renderDpia(data, slot) {
const matrixHtml = renderMatrixHtml(data, 5);
const threatsHtml = renderThreatsTable(data.threats);
const rp = data.residualPair;
let residualHtml = '';
if (rp && rp.before && rp.after) {
const sevFor = function (s) {
if (s == null) return '';
if (s >= 16) return 'critical';
if (s >= 9) return 'high';
if (s >= 4) return 'medium';
return 'low';
};
const labelOf = function (cell) {
if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
return cell.label || '—';
};
const sevBefore = rp.before.score != null ? sevFor(rp.before.score) : '';
const sevAfter = rp.after.score != null ? sevFor(rp.after.score) : '';
const cellBefore = 'pair-before-after__cell' + (sevBefore ? ' pair-before-after__cell--severity-' + sevBefore : '');
const cellAfter = 'pair-before-after__cell' + (sevAfter ? ' pair-before-after__cell--severity-' + sevAfter : '');
residualHtml = '<div class="pair-before-after">' +
'<div class="' + cellBefore + '">' +
'<span class="pair-before-after__cell-label">FØR TILTAK</span>' +
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.before)) + '</span>' +
'<span class="pair-before-after__cell-meta">Sannsynlighet × konsekvens</span>' +
'</div>' +
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
'<div class="' + cellAfter + '">' +
'<span class="pair-before-after__cell-label">ETTER TILTAK</span>' +
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.after)) + '</span>' +
'<span class="pair-before-after__cell-meta">Restrisiko</span>' +
'</div>' +
'</div>';
}
const body = matrixHtml + residualHtml + threatsHtml;
// Utvid matrix-risk-keyStats med RESTRISIKO når residualPair finnes.
const baseStats = inferKeyStats(data, 'matrix-risk');
const stats = (data.keyStats || (rp && rp.after
? baseStats.concat([{
label: 'RESTRISIKO',
value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
hint: 'etter tiltak'
}])
: baseStats));
slot.innerHTML = renderPageShell({
eyebrow: 'DPIA',
title: data.title || 'DPIA / Personvernkonsekvensvurdering',
lede: data.lede || 'Risikomatrise og tiltak iht. Datatilsynets metodikk og GDPR Art. 35.',
verdict: data.verdict || inferVerdict(data, 'matrix-risk'),
keyStats: stats
}, body);
}
// ---- Sub-batch B: Security (3) ----
function renderSecurity(data, slot) {
const sevForScore = function (s) {
const n = Number(s) || 0;
if (n >= 16) return 'critical';
if (n >= 9) return 'high';
if (n >= 4) return 'medium';
return 'low';
};
const matrixHtml = renderMatrixHtml(data, 6);
const radarHtml = renderRadarSvg(data.dimensions || []);
// C7 small-multiples per OWASP-kategori (driver: categoryGrades).
const cats = data.categoryGrades || [];
const smallMultiplesHtml = cats.length ? '<div class="small-multiples">' + cats.map(function (c) {
const grade = c.grade || '';
const fillPct = Math.max(0, Math.min(100, ((Number(c.score) || 0) / 5) * 100));
return '<div class="sm-card">' +
'<div class="sm-card__header">' +
'<span class="sm-card__name">' + escapeHtml(c.name || '') + '</span>' +
'<span class="sm-card__grade" data-grade="' + escapeAttr(grade) + '">' + escapeHtml(grade) + '</span>' +
'</div>' +
'<div class="sm-card__bar"><div class="sm-card__bar-fill" style="width: ' + fillPct.toFixed(0) + '%"></div></div>' +
'<span class="sm-card__status">Score ' + escapeHtml(String(c.score || 0)) + ' / 5</span>' +
'</div>';
}).join('') + '</div>' : '';
// C4 top-risks-list (max 5).
const topRisks = (data.topRisks || []).slice(0, 5);
const topRisksHtml = topRisks.length ? '<section class="top-risks">' +
'<h4 class="top-risks__heading">Top-risikoer</h4>' +
topRisks.map(function (r, i) {
const sev = r.severity || sevForScore(r.score);
return '<div class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
'<span class="top-risk__rank">' + (i + 1) + '</span>' +
'<span class="top-risk__desc">' + escapeHtml(r.description || '') + '</span>' +
'<span class="top-risk__score">' + escapeHtml(String(r.score || 0)) + '</span>' +
'</div>';
}).join('') + '</section>' : '';
// B6 residual-pair (når data.residualPair finnes).
const rp = data.residualPair;
let residualHtml = '';
if (rp && rp.before && rp.after) {
const labelOf = function (cell) {
if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
return cell.label || '—';
};
const sevBefore = rp.before.score != null ? sevForScore(rp.before.score) : '';
const sevAfter = rp.after.score != null ? sevForScore(rp.after.score) : '';
const cellBefore = 'pair-before-after__cell' + (sevBefore ? ' pair-before-after__cell--severity-' + sevBefore : '');
const cellAfter = 'pair-before-after__cell' + (sevAfter ? ' pair-before-after__cell--severity-' + sevAfter : '');
residualHtml = '<div class="pair-before-after">' +
'<div class="' + cellBefore + '">' +
'<span class="pair-before-after__cell-label">FØR TILTAK</span>' +
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.before)) + '</span>' +
'<span class="pair-before-after__cell-meta">Sannsynlighet × konsekvens</span>' +
'</div>' +
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
'<div class="' + cellAfter + '">' +
'<span class="pair-before-after__cell-label">ETTER TILTAK</span>' +
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.after)) + '</span>' +
'<span class="pair-before-after__cell-meta">Restrisiko</span>' +
'</div>' +
'</div>';
}
const findingsHtml = renderFindingsBlock(data.findings || [], 'Sikkerhetsfunn');
const body = matrixHtml + radarHtml + smallMultiplesHtml + topRisksHtml + residualHtml + findingsHtml;
// Utvid matrix-risk-6x5-keyStats med RESTRISIKO når residualPair finnes.
const baseStats = inferKeyStats(data, 'matrix-risk-6x5');
const stats = (data.keyStats || (rp && rp.after
? baseStats.concat([{
label: 'RESTRISIKO',
value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
hint: 'etter tiltak'
}])
: baseStats));
slot.innerHTML = renderPageShell({
eyebrow: 'SIKKERHET',
title: data.title || 'Sikkerhetsvurdering (6×5)',
lede: data.lede || 'Score per dimensjon, risikomatrise og topp-risikoer mot NSM, Microsoft Cloud Security og AI Act Art. 15.',
verdict: data.verdict || inferVerdict(data, 'matrix-risk-6x5'),
keyStats: stats
}, body);
}
function renderRos(data, slot) {
const sevForScore = function (s) {
const n = Number(s) || 0;
if (n >= 16) return 'critical';
if (n >= 9) return 'high';
if (n >= 4) return 'medium';
return 'low';
};
const matrixHtml = renderMatrixHtml(data, 5);
const radarHtml = renderRadarSvg(data.radar_axes || []);
// C4 top-risks-list (max 5).
const topRisks = (data.topRisks || []).slice(0, 5);
const topRisksHtml = topRisks.length ? '<section class="top-risks">' +
'<h4 class="top-risks__heading">Top-risikoer</h4>' +
topRisks.map(function (r, i) {
const sev = r.severity || sevForScore(r.score);
const scoreLabel = r.score ? String(r.score) : (r.severity || '—').toUpperCase();
return '<div class="top-risk" data-severity="' + escapeAttr(sev) + '">' +
'<span class="top-risk__rank">' + (i + 1) + '</span>' +
'<span class="top-risk__desc">' + escapeHtml(r.description || '') + '</span>' +
'<span class="top-risk__score">' + escapeHtml(scoreLabel) + '</span>' +
'</div>';
}).join('') + '</section>' : '';
// B6 residual-pair (gjenbruker mønster fra Dpia / Security).
const rp = data.residualPair;
let residualHtml = '';
if (rp && rp.before && rp.after) {
const labelOf = function (cell) {
if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
return cell.label || '—';
};
const sevBefore = rp.before.score != null ? sevForScore(rp.before.score) : '';
const sevAfter = rp.after.score != null ? sevForScore(rp.after.score) : '';
const cellBefore = 'pair-before-after__cell' + (sevBefore ? ' pair-before-after__cell--severity-' + sevBefore : '');
const cellAfter = 'pair-before-after__cell' + (sevAfter ? ' pair-before-after__cell--severity-' + sevAfter : '');
residualHtml = '<div class="pair-before-after">' +
'<div class="' + cellBefore + '">' +
'<span class="pair-before-after__cell-label">FØR TILTAK</span>' +
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.before)) + '</span>' +
'<span class="pair-before-after__cell-meta">Sannsynlighet × konsekvens</span>' +
'</div>' +
'<div class="pair-before-after__arrow" aria-hidden="true"></div>' +
'<div class="' + cellAfter + '">' +
'<span class="pair-before-after__cell-label">ETTER TILTAK</span>' +
'<span class="pair-before-after__cell-value">' + escapeHtml(labelOf(rp.after)) + '</span>' +
'<span class="pair-before-after__cell-meta">Restrisiko</span>' +
'</div>' +
'</div>';
}
// D5 recommendation-card.
const rec = data.recommendation || '';
const recommendationHtml = rec ? '<aside class="recommendation-card">' +
'<span class="recommendation-card__label">Anbefaling</span>' +
'<p class="recommendation-card__body">' + escapeHtml(rec).replace(/\n/g, '<br>') + '</p>' +
'</aside>' : '';
const threatsHtml = renderThreatsTable(data.threats);
const body = matrixHtml + radarHtml + topRisksHtml + residualHtml + threatsHtml + recommendationHtml;
// Utvid matrix-risk-keyStats med RESTRISIKO når residualPair finnes
// (samme mønster som renderDpia).
const baseStats = inferKeyStats(data, 'matrix-risk');
const stats = (data.keyStats || (rp && rp.after
? baseStats.concat([{
label: 'RESTRISIKO',
value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
hint: 'etter tiltak'
}])
: baseStats));
slot.innerHTML = renderPageShell({
eyebrow: 'ROS',
title: data.title || 'ROS-analyse (5×5)',
lede: data.lede || 'Risiko- og sårbarhetsanalyse iht. NS 5814 / ISO 31000 med AI-trusselbibliotek.',
verdict: data.verdict || inferVerdict(data, 'matrix-risk'),
keyStats: stats
}, body);
}
function renderReview(data, slot) {
const buckets = data.buckets || { keep: [], review: [], remove: [], suppressed: [] };
const cardFor = function (bucket, label) {
const items = buckets[bucket] || [];
const cards = items.length ? items.map(function (it) {
const sev = (it.severity || '').toUpperCase();
const head = it.id ? (it.id + ' — ' + (it.location || '')) : (it.location || '');
const recommendation = it.recommendation ? '<div class="kanban-card__meta">' + escapeHtml(it.recommendation) + '</div>' : '';
const sevTag = sev ? '<div class="kanban-card__meta">Severity: ' + escapeHtml(sev) + '</div>' : '';
return '<div class="kanban-card" data-severity="' + escapeAttr(it.severity || '') + '">' +
'<div class="kanban-card__name">' + escapeHtml(head) + '</div>' +
sevTag +
recommendation +
'</div>';
}).join('') : '<div class="kanban-col__empty">Ingen funn</div>';
return '<div class="kanban-col" data-bucket="' + escapeAttr(bucket) + '">' +
'<div class="kanban-col__head">' +
'<span class="kanban-col__title">' + escapeHtml(label) + '</span>' +
'<span class="kanban-col__count">' + items.length + '</span>' +
'</div>' +
cards +
'</div>';
};
const kanbanHtml = '<div class="kanban-board">' +
cardFor('keep', 'Keep') +
cardFor('review', 'Review') +
cardFor('remove', 'Remove') +
'</div>';
// E6 suppressed-panel for waived/akseptert items (collapsed by default).
const suppressed = buckets.suppressed || [];
const suppressedHtml = suppressed.length ? '<details class="suppressed-panel">' +
'<summary>Undertrykt (' + suppressed.length + ') — godtatt eller waiver registrert</summary>' +
'<div class="suppressed-panel__list">' + suppressed.map(function (it) {
return '<div class="suppressed-panel__item">' +
'<span class="suppressed-panel__id">' + escapeHtml(it.id || '—') + '</span>' +
'<span>' + escapeHtml(it.location || it.recommendation || '') + '</span>' +
'</div>';
}).join('') + '</div>' +
'</details>' : '';
const body = kanbanHtml + suppressedHtml;
// KeyStats: utvid 'findings'-archetype med BUCKET-stats (KEEP/REVIEW/REMOVE).
const baseStats = inferKeyStats(data, 'findings');
const stats = data.keyStats || baseStats.concat([
{ label: 'KEEP', value: (buckets.keep || []).length, modifier: 'low' },
{ label: 'REVIEW', value: (buckets.review || []).length, modifier: (buckets.review || []).length ? 'high' : 'low' },
{ label: 'REMOVE', value: (buckets.remove || []).length, modifier: (buckets.remove || []).length ? 'critical' : 'low' }
]);
slot.innerHTML = renderPageShell({
eyebrow: 'REVIEW',
title: data.title || 'Arkitekturgjennomgang',
lede: data.lede || 'Funn fordelt på Keep / Review / Remove med suppressed-panel for waived items.',
verdict: data.verdict || inferVerdict(data, 'findings'),
keyStats: stats
}, body);
}
// ---- 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 '<div class="distribution__row">' +
'<div class="distribution__label">' + escapeHtml(r.label) + '</div>' +
'<div class="distribution__track">' +
'<div class="distribution__band" style="left: 0%; width: ' + w.toFixed(1) + '%"></div>' +
'<div class="distribution__median" style="left: ' + w.toFixed(1) + '%">' +
'<span class="distribution__median-label">' + r.value.toLocaleString('nb-NO') + ' NOK</span>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
const distHtml =
'<div class="distribution">' + distRows +
'<div class="distribution__axis"><div class="distribution__axis-ticks">' +
'<span>0</span><span>' + Math.floor(max / 2).toLocaleString('nb-NO') + '</span><span>' + max.toLocaleString('nb-NO') + ' NOK/mnd</span>' +
'</div></div>' +
'</div>';
const breakdownRows = (data.monthly_breakdown || []).map(function (m) {
return '<tr><td>' + escapeHtml(m.component) + '</td><td>' + m.cost.toLocaleString('nb-NO') + ' NOK</td></tr>';
}).join('');
const breakdownHtml = breakdownRows
? '<table class="report-table"><thead><tr><th>Komponent</th><th>NOK/mnd</th></tr></thead><tbody>' + breakdownRows + '</tbody></table>'
: '';
const tcoHeaders = data.tco_headers || [];
const tcoHeader = tcoHeaders.map(function (h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('');
const tcoRows = (data.tco_table || []).map(function (r) {
const cells = tcoHeaders.map(function (h) { return '<td>' + escapeHtml(r[h] || '') + '</td>'; }).join('');
return '<tr>' + cells + '</tr>';
}).join('');
const tcoHtml = tcoRows
? '<table class="report-table"><thead><tr>' + tcoHeader + '</tr></thead><tbody>' + tcoRows + '</tbody></table>'
: '';
const body = distHtml + breakdownHtml + tcoHtml;
// Utvid cost-distribution-keyStats med DOMINERENDE (top-komponent i breakdown).
const breakdown = data.monthly_breakdown || [];
const dominant = breakdown.reduce(function (acc, m) {
return (m && Number(m.cost) > Number(acc && acc.cost || 0)) ? m : acc;
}, null);
const baseStats = inferKeyStats(data, 'cost-distribution');
const stats = data.keyStats || (dominant
? baseStats.concat([{
label: 'DOMINERENDE',
value: String(dominant.component || '').slice(0, 28),
hint: formatNok(dominant.cost) + '/mnd'
}])
: baseStats);
slot.innerHTML = renderPageShell({
eyebrow: 'KOSTNAD',
title: data.title || 'Kostnadsestimat',
lede: data.lede || 'Distribusjon P10/P50/P90 i NOK med månedlig fordeling og TCO over 3 år.',
verdict: data.verdict || inferVerdict(data, 'cost-distribution'),
keyStats: stats
}, body);
}
function renderLicense(data, slot) {
const licenses = data.licenses || [];
if (!licenses.length) { slot.innerHTML = renderEmptyState(); return; }
const headHtml =
'<div class="capability-matrix__head">' +
'<div class="capability-matrix__head-cell capability-matrix__head-cell--name">Kapabilitet</div>' +
licenses.map(function (l) {
return '<div class="capability-matrix__head-cell">' + escapeHtml(l.name) + '</div>';
}).join('') +
'</div>';
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 '<div class="capability-matrix__cell" data-status="' + escapeAttr(status) + '">' +
'<div class="capability-matrix__cell-icon"></div>' +
'</div>';
}).join('');
return '<div class="capability-matrix__row">' +
'<div class="capability-matrix__name">' + escapeHtml(capName) + '</div>' +
cells +
'</div>';
}).join('');
const matrixHtml = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
headHtml + rowsHtml + '</div>';
// D1 scenario-card-grid per lisens: hver lisens som card med dekning-stat.
const isAvail = function (cap) { return /^avail|tilgjengelig/i.test((cap && cap.status) || ''); };
const isMiss = function (cap) { return /^miss/i.test((cap && cap.status) || ''); };
const totalCaps = capabilityNames.length;
const licScores = licenses.map(function (l) {
const caps = l.capabilities || [];
const avail = caps.filter(isAvail).length;
const miss = caps.filter(isMiss).length;
const ratio = totalCaps ? (avail / totalCaps) : 0;
const status = ratio >= 0.8 ? 'met' : ratio >= 0.4 ? 'partial' : 'missing';
return { name: l.name, avail: avail, miss: miss, total: totalCaps, ratio: ratio, status: status };
});
const scenarioGridHtml = '<div class="scenario-card-grid">' + licScores.map(function (s) {
const pct = (s.ratio * 100).toFixed(0);
return '<div class="scenario-card" data-status="' + escapeAttr(s.status) + '">' +
'<div class="scenario-card__head">' +
'<span class="scenario-card__source">LISENS</span>' +
'<span class="scenario-card__count">' + s.avail + '/' + s.total + '</span>' +
'</div>' +
'<h4 class="scenario-card__title">' + escapeHtml(s.name) + '</h4>' +
'<div class="scenario-card__source">' + pct + '% dekket · ' + s.miss + ' mangler</div>' +
'</div>';
}).join('') + '</div>';
const body = scenarioGridHtml + matrixHtml;
// Utvid capability-keyStats med BESTE LISENS (høyest avail-ratio).
const baseStats = inferKeyStats(data, 'capability');
const top = licScores.reduce(function (a, b) { return b.ratio > (a ? a.ratio : -1) ? b : a; }, null);
const stats = data.keyStats || (top
? baseStats.concat([{
label: 'TOPP-LISENS',
value: String(top.name || '').slice(0, 24),
hint: top.avail + '/' + top.total + ' kapabiliteter',
modifier: top.status === 'met' ? 'low' : top.status === 'partial' ? 'medium' : 'high'
}])
: baseStats);
slot.innerHTML = renderPageShell({
eyebrow: 'LISENS',
title: data.title || 'Lisens-kapabilitetsmatrise',
lede: data.lede || 'Kapabilitetsdekning per lisensnivå med scenario-cards og full matrise.',
verdict: data.verdict || inferVerdict(data, 'capability'),
keyStats: stats
}, body);
}
// ---- Sub-batch D: Documentation (6) ----
function renderMigrate(data, slot) {
const phases = data.phases || [];
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
// Map fase-status til mat-step data-state. R15: hvis ingen faser har
// status, fall tilbake til "alle future" — eksisterende fixtures uberørt.
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
const stepStateFor = function (p, i) {
if (p.status === 'done') return 'completed';
if (p.status === 'active') return 'current';
if (p.status === 'planned' || p.status) return 'future';
// Fallback uten status: bruk currentPhaseIndex hvis satt.
if (cpi < 0) return 'future';
if (i < cpi) return 'completed';
if (i === cpi) return 'current';
return 'future';
};
const stepsHtml = phases.map(function (p, i) {
const state = stepStateFor(p, i);
const num = String(i + 1).padStart(2, '0');
const pill = state === 'current'
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
: state === 'completed'
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
: '';
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
const desc = (p.milestones && p.milestones.length)
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
: '';
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon">' + num + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
desc +
dur +
'</div>' +
'</div>';
}).join('');
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
// E4 cycle-ribbon: bare når en fase er aktiv. data-phase=execution som
// standard for migrasjonens "kjøre"-fase.
let ribbonHtml = '';
if (cpi >= 0 && phases[cpi]) {
const cur = phases[cpi];
const cumWeeks = phases.slice(0, cpi).reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
const weekStart = cumWeeks + 1;
const weekEnd = cumWeeks + (Number(cur.duration_weeks) || 0);
const weekRange = cur.duration_weeks ? ('Uke ' + weekStart + '-' + weekEnd) : '';
ribbonHtml = '<div class="cycle-ribbon" data-phase="execution">' +
'<span class="cycle-ribbon__id">M-' + (cpi + 1) + '</span>' +
(weekRange ? '<span class="cycle-ribbon__week">' + escapeHtml(weekRange) + '</span>' : '') +
'<span class="cycle-ribbon__phase">PÅGÅR</span>' +
'<span class="cycle-ribbon__msg">' + escapeHtml(cur.name) + '</span>' +
'</div>';
}
const detailsHtml = phases.map(function (p) {
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
const sc = (p.success_criteria || []).map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
return '<section class="phase-detail">' +
'<h3>' + escapeHtml(p.name) + ' <small>(' + (p.duration_weeks || '?') + ' uker)</small></h3>' +
(ms ? '<h4>Milepæler</h4><ul>' + ms + '</ul>' : '') +
(sc ? '<h4>Suksesskriterier</h4><ul>' + sc + '</ul>' : '') +
'</section>';
}).join('');
const risksRows = (data.risks || []).map(function (r) {
return '<tr><td>' + escapeHtml(r.risk || '') + '</td><td>' + escapeHtml(r.probability || '') + '</td><td>' + escapeHtml(r.consequence || '') + '</td><td>' + escapeHtml(r.mitigation || '') + '</td></tr>';
}).join('');
const risksHtml = risksRows
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
: '';
const body = ribbonHtml + ladderHtml + detailsHtml + risksHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'MIGRASJON',
title: data.title || 'Migrasjonsplan',
lede: data.lede || 'Faseinndelt migrasjon med mat-ladder, cycle-ribbon for aktiv fase og risikomatrise.',
verdict: data.verdict || inferVerdict(data, 'phased-plan'),
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
}, body);
}
function renderAdr(data, slot) {
const meta =
'<dl class="adr-meta">' +
(data.status ? '<dt>Status</dt><dd>' + escapeHtml(data.status) + '</dd>' : '') +
(data.date ? '<dt>Date</dt><dd>' + escapeHtml(data.date) + '</dd>' : '') +
(data.deciders ? '<dt>Deciders</dt><dd>' + escapeHtml(data.deciders) + '</dd>' : '') +
'</dl>';
// D4 critique-card per beslutningsseksjon. Ingen severity (ADR-seksjoner
// er ikke risikorangert), bruker rekkefølge-id ADR-01..n.
const sections = data.sections || [];
const cardsHtml = sections.length ? '<div class="critique-cards">' + sections.map(function (s, i) {
const id = 'ADR-' + String(i + 1).padStart(2, '0');
const body = escapeHtml(s.body || '').replace(/\n/g, '<br>');
return '<div class="critique-card">' +
'<div class="critique-card__header">' +
'<div class="critique-card__title">' + escapeHtml(s.heading) + '</div>' +
'<div class="critique-card__meta">' +
'<span class="critique-card__id">' + id + '</span>' +
'</div>' +
'</div>' +
'<div class="critique-card__recommendation">' + body + '</div>' +
'</div>';
}).join('') + '</div>' : '';
const body = meta + cardsHtml;
// ADR-status til verdict: accepted/godkjent → approved, proposed → go-with-conditions,
// rejected → failed, deprecated/superseded → warning.
const statusMap = {
accepted: 'approved', godkjent: 'approved', approved: 'approved',
proposed: 'go-with-conditions', foreslått: 'go-with-conditions', 'foreslatt': 'go-with-conditions',
rejected: 'failed', avvist: 'failed',
deprecated: 'warning', foreldet: 'warning', superseded: 'warning'
};
const verdict = data.verdict || statusMap[String(data.status || '').toLowerCase().trim()] || 'n-a';
slot.innerHTML = renderPageShell({
eyebrow: 'ADR',
title: data.title || 'Architecture Decision Record',
lede: data.lede || (data.status ? 'Status: ' + data.status : 'MADR v3.0 — beslutningsdokument med kontekst, alternativer og konsekvenser.'),
verdict: verdict,
keyStats: data.keyStats || [
{ label: 'STATUS', value: String(data.status || '—').toUpperCase() },
{ label: 'SEKSJONER', value: sections.length },
{ label: 'BESLUTTERE', value: data.deciders ? String(data.deciders).split(/[,;]/).length : 0, hint: 'antall' }
]
}, body);
}
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 =
'<div class="verdict-block">' +
'<div class="verdict-pill-lg" data-verdict="' + escapeAttr(v.variant) + '">' +
'<div class="verdict-pill-lg__verdict">' + escapeHtml(v.label) + '</div>' +
'<div class="verdict-pill-lg__sub">' + escapeHtml(data.sub || 'AI-vurdering') + '</div>' +
'</div>' +
'<div class="risk-meter">' +
'<div class="risk-meter__readout">' +
'<span class="risk-meter__score">' + score + '</span>' +
'<span class="risk-meter__band-label">heuristisk score (0-100)</span>' +
'</div>' +
'<div class="risk-meter__track">' +
'<div class="risk-meter__pointer" style="left: ' + score + '%"></div>' +
'</div>' +
'<div class="risk-meter__bands">' +
'<span>Allow</span><span>Notice</span><span>Warning</span><span>Block</span><span>Critical</span>' +
'</div>' +
'</div>' +
'</div>';
// E8 read-more: lange rationale (>300 tegn) skjuler hale i <details>.
let rationaleHtml = '';
if (data.rationale) {
const raw = String(data.rationale);
if (raw.length > 300) {
const head = raw.slice(0, 220);
const tail = raw.slice(220);
rationaleHtml = '<section><h3>Rationale</h3>' +
'<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
'<details class="read-more-block"><summary>Vis hele rationale</summary>' +
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
'</details>' +
'</section>';
} else {
rationaleHtml = '<section><h3>Rationale</h3><p>' + escapeHtml(raw).replace(/\n/g, '<br>') + '</p></section>';
}
}
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 '<th>' + escapeHtml(h) + '</th>'; }).join('');
const rows = data.key_metrics.map(function (m) {
const cells = headers.map(function (h) { return '<td>' + escapeHtml(m[h] || '') + '</td>'; }).join('');
return '<tr>' + cells + '</tr>';
}).join('');
metricsHtml = '<section><h3>Key Metrics</h3><table class="report-table"><thead><tr>' + headerRow + '</tr></thead><tbody>' + rows + '</tbody></table></section>';
}
const nextHtml = (data.next_steps || []).length
? '<section><h3>Next Steps</h3><ul>' + data.next_steps.map(function (s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('') + '</ul></section>'
: '';
const body = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
// Map summary-verdict (allow/warning/block) til canonical verdict for header-pill.
const headerVerdictMap = { allow: 'allow', warning: 'warning', block: 'block' };
const headerVerdict = headerVerdictMap[v.variant] || 'warning';
slot.innerHTML = renderPageShell({
eyebrow: 'SAMMENDRAG',
title: data.title || 'Beslutningsnotat',
lede: data.lede || 'Teknisk sammendrag med verdict, key metrics og neste steg.',
verdict: headerVerdict,
keyStats: data.keyStats || inferKeyStats(data, 'verdict')
}, body);
}
function renderPoc(data, slot) {
const phases = data.phases || [];
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
// E2 mat-ladder (samme mønster som migrate). POC uses currentPhaseIndex/status.
const cpi = (typeof data.currentPhaseIndex === 'number') ? data.currentPhaseIndex : -1;
const stepStateFor = function (p, i) {
if (p.status === 'done') return 'completed';
if (p.status === 'active') return 'current';
if (p.status === 'planned' || p.status) return 'future';
if (cpi < 0) return 'future';
if (i < cpi) return 'completed';
if (i === cpi) return 'current';
return 'future';
};
const stepsHtml = phases.map(function (p, i) {
const state = stepStateFor(p, i);
const num = String(i + 1).padStart(2, '0');
const pill = state === 'current'
? '<span class="mat-step__pill mat-step__pill--current">PÅGÅR</span>'
: state === 'completed'
? '<span class="mat-step__pill mat-step__pill--complete">FERDIG</span>'
: '';
const dur = p.duration_weeks ? '<div class="mat-step__progress"><span>' + p.duration_weeks + ' uker</span></div>' : '';
const desc = (p.milestones && p.milestones.length)
? '<div class="mat-step__desc">' + escapeHtml(p.milestones[0]) + '</div>'
: '';
return '<div class="mat-step" data-state="' + escapeAttr(state) + '">' +
'<div class="mat-step__icon">' + num + '</div>' +
'<div>' +
'<div class="mat-step__name">' + escapeHtml(p.name) + ' ' + pill + '</div>' +
desc +
dur +
'</div>' +
'</div>';
}).join('');
const ladderHtml = '<div class="mat-ladder">' + stepsHtml + '</div>';
// B5 traffic-light per success-kriterie. R15: uten eksplisitt status,
// bruk fasens state — done=go, active=warning, future=neutral.
const detailsHtml = phases.map(function (p, i) {
const state = stepStateFor(p, i);
const tlStatus = state === 'completed' ? 'green' : state === 'current' ? 'yellow' : 'gray';
const ms = (p.milestones || []).map(function (m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
const sc = (p.success_criteria || []).map(function (s) {
return '<li class="traffic-row">' +
'<span class="traffic-light" data-status="' + escapeAttr(tlStatus) + '" aria-label="' + escapeAttr(tlStatus) + '">' +
'<span class="traffic-light__dot"></span>' +
'<span class="traffic-light__label">' + escapeHtml(s) + '</span>' +
'</span>' +
'</li>';
}).join('');
return '<section class="phase-detail">' +
'<h3>' + escapeHtml(p.name) + ' <small>(' + (p.duration_weeks || '?') + ' uker)</small></h3>' +
(ms ? '<h4>Milepæler</h4><ul>' + ms + '</ul>' : '') +
(sc ? '<h4>Suksesskriterier</h4><ul class="traffic-list">' + sc + '</ul>' : '') +
'</section>';
}).join('');
const risksRows = (data.risks || []).map(function (r) {
return '<tr><td>' + escapeHtml(r.risk || '') + '</td><td>' + escapeHtml(r.probability || '') + '</td><td>' + escapeHtml(r.consequence || '') + '</td><td>' + escapeHtml(r.mitigation || '') + '</td></tr>';
}).join('');
const risksHtml = risksRows
? '<table class="report-table"><thead><tr><th>Risiko</th><th>Sannsynlighet</th><th>Konsekvens</th><th>Tiltak</th></tr></thead><tbody>' + risksRows + '</tbody></table>'
: '';
const body = ladderHtml + detailsHtml + risksHtml;
// B1 verdict-pille: data.pocVerdict styrer (go/go-with-conditions/block).
// R15: hvis ikke satt, fall tilbake til risk-baserte heuristikk.
let verdict = data.verdict || data.pocVerdict;
if (!verdict) {
const risks = data.risks || [];
const critical = risks.some(function (r) { return /high|h[øo]y/i.test(r.consequence || '') && /high|h[øo]y/i.test(r.probability || ''); });
verdict = critical ? 'go-with-conditions' : (risks.length ? 'go-with-conditions' : 'go');
}
slot.innerHTML = renderPageShell({
eyebrow: 'POC',
title: data.title || 'POC-plan',
lede: data.lede || 'Faseinndelt POC med mat-ladder, suksesskriterier og go/no-go-vurdering.',
verdict: verdict,
keyStats: data.keyStats || inferKeyStats(data, 'phased-plan')
}, body);
}
function renderUtredning(data, slot) {
const sections = data.sections || [];
// A4 SCREEN-TABS: kuratert sett av 4 strukturerte tabs.
// R15: Hvis utredningen mangler en av seksjonene, hopp over den taben.
const tabSpec = [
{ id: 'bakgrunn', label: 'Bakgrunn', match: /\bbakgrunn\b/i },
{ id: 'funn', label: 'Funn', match: /\bfunn\b/i },
{ id: 'konklusjon', label: 'Konklusjon', match: /\bkonklusjon\b/i },
{ id: 'anbefaling', label: 'Anbefaling', match: /\banbefaling\b/i }
];
// Heading-normaliser: fjern "1. ", "1.2 " prefiks.
const normalize = function (h) { return String(h || '').replace(/^\s*\d+(\.\d+)*\s*\.?\s*/, '').trim(); };
const findSec = function (m) {
return sections.find(function (s) { return m.test(normalize(s.heading)); });
};
const usedIdx = new Set();
const tabs = tabSpec.map(function (t) {
const sec = findSec(t.match);
if (sec) usedIdx.add(sections.indexOf(sec));
return { id: t.id, label: t.label, sec: sec };
}).filter(function (t) { return t.sec; });
// E8 read-more body: lange seksjoner (>500 tegn) skjuler hale i <details>.
const renderBody = function (raw) {
const txt = String(raw || '');
if (txt.length > 500) {
const head = txt.slice(0, 380);
const tail = txt.slice(380);
return '<p>' + escapeHtml(head).replace(/\n/g, '<br>') + '…</p>' +
'<details class="read-more-block"><summary>Vis hele seksjonen</summary>' +
'<p>' + escapeHtml(tail).replace(/\n/g, '<br>') + '</p>' +
'</details>';
}
return '<div>' + escapeHtml(txt).replace(/\n/g, '<br>') + '</div>';
};
const tabsNavHtml = tabs.length ? '<nav class="tab-list" role="tablist" aria-label="Utredning">' + tabs.map(function (t, i) {
return '<a class="tab" role="tab" aria-current="' + (i === 0 ? 'true' : 'false') + '" href="#utr-' + escapeAttr(t.id) + '">' + escapeHtml(t.label) + '</a>';
}).join('') + '</nav>' : '';
const tabsBodyHtml = tabs.map(function (t) {
return '<section id="utr-' + escapeAttr(t.id) + '" class="utr-panel">' +
'<h2>' + escapeHtml(normalize(t.sec.heading)) + '</h2>' +
renderBody(t.sec.body) +
'</section>';
}).join('');
// Resterende seksjoner (mandat, metode, referanser m.fl.) under en samlende read-more.
const otherSecs = sections.filter(function (s, i) { return !usedIdx.has(i); });
const otherHtml = otherSecs.length ? '<details class="read-more-block utr-other"><summary>Vis øvrige seksjoner (' + otherSecs.length + ')</summary>' +
otherSecs.map(function (s) {
return '<section><h3>' + escapeHtml(normalize(s.heading)) + '</h3>' + renderBody(s.body) + '</section>';
}).join('') +
'</details>' : '';
const body = tabsNavHtml + tabsBodyHtml + otherHtml;
slot.innerHTML = renderPageShell({
eyebrow: 'UTREDNING',
title: data.title || 'AI-arkitekturutredning',
lede: data.lede || 'Strukturert utredning med kuraterte seksjoner: bakgrunn, funn, konklusjon og anbefaling.',
verdict: data.verdict || 'n-a',
keyStats: data.keyStats || [
{ label: 'TABS', value: tabs.length, hint: 'av 4' },
{ label: 'SEKSJONER', value: sections.length },
{ label: 'ØVRIGE', value: otherSecs.length, hint: 'andre seksjoner' }
]
}, body);
}
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++;
});
// Vinner: eksplisitt parseComparison.winner ELLER auto fra row-counts.
const explicitWin = String(data.winner || '').toLowerCase();
let winnerIdx = -1;
if (explicitWin) {
if (fw1 && explicitWin.indexOf(fw1) >= 0) winnerIdx = 0;
else if (fw2 && explicitWin.indexOf(fw2) >= 0) winnerIdx = 1;
}
if (winnerIdx < 0 && (count1 || count2)) {
winnerIdx = count1 > count2 ? 0 : count2 > count1 ? 1 : -1;
}
// D1 scenario-cards-grid per kandidat. Vinner får data-status="met",
// taper "partial", tied/no-winner forblir "partial".
const cardSubjects = subjects.map(function (s, i) {
const wins = i === 0 ? count1 : count2;
const status = i === winnerIdx ? 'met' : 'partial';
const total = (data.rows || []).length;
return { name: s, wins: wins, total: total, status: status, isWinner: i === winnerIdx };
});
const cardsHtml = '<div class="scenario-card-grid">' + cardSubjects.map(function (c) {
const winnerBadge = c.isWinner ? '<span class="scenario-card__count" style="background: var(--color-state-success); color: #fff;">VINNER</span>' : '<span class="scenario-card__count">' + c.wins + '/' + c.total + '</span>';
return '<div class="scenario-card" data-status="' + escapeAttr(c.status) + '">' +
'<div class="scenario-card__head">' +
'<span class="scenario-card__source">KANDIDAT</span>' +
winnerBadge +
'</div>' +
'<h4 class="scenario-card__title">' + escapeHtml(c.name) + '</h4>' +
'<div class="scenario-card__source">' + c.wins + ' vinn · ' + (c.total - c.wins) + ' lik/tap</div>' +
'</div>';
}).join('') + '</div>';
const summaryHtml =
'<div class="diff__summary">' +
'<div class="diff__summary-item"><span class="diff__summary-count">' + count1 + '</span> ' + escapeHtml(subjects[0]) + '</div>' +
'<div class="diff__summary-item"><span class="diff__summary-count">' + count2 + '</span> ' + escapeHtml(subjects[1]) + '</div>' +
'<div class="diff__summary-item"><span class="diff__summary-count">' + lik + '</span> Lik</div>' +
'</div>';
const headerHtml =
'<div class="diff__row">' +
'<div class="diff__cell diff__cell--unchanged"><strong>' + escapeHtml(subjects[0]) + '</strong></div>' +
'<div class="diff__cell diff__cell--unchanged"><strong>' + escapeHtml(subjects[1]) + '</strong></div>' +
'</div>';
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 '<div class="diff__row">' +
'<div class="diff__cell ' + cls1 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value1) + '</div>' +
'<div class="diff__cell ' + cls2 + '"><strong>' + escapeHtml(r.aspect) + ':</strong> ' + escapeHtml(r.value2) + '</div>' +
'</div>';
}).join('');
const diffHtml = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
const body = cardsHtml + diffHtml;
// Verdict-pille: vinner satt → 'go' (klar anbefaling). Tied/uavklart → 'go-with-conditions'.
const verdict = data.verdict || (winnerIdx >= 0 ? 'go' : 'go-with-conditions');
// Utvid comparison-keyStats med VINNER-felt.
const baseStats = inferKeyStats(data, 'comparison');
const stats = data.keyStats || (winnerIdx >= 0
? baseStats.concat([{
label: 'VINNER',
value: String(subjects[winnerIdx] || '').slice(0, 24),
hint: (winnerIdx === 0 ? count1 : count2) + ' vinn',
modifier: 'low'
}])
: baseStats.concat([{ label: 'VINNER', value: 'UAVKLART', modifier: 'medium' }]));
slot.innerHTML = renderPageShell({
eyebrow: 'SAMMENLIGN',
title: data.title || 'Sammenligning',
lede: data.lede || 'Aspekt-for-aspekt-sammenligning av to kandidater med vinner-pille og diff-tabell.',
verdict: verdict,
keyStats: stats
}, body);
}
// === V2_FOUNDATION_BEGIN ===
// ============================================================
// FOUNDATION HELPERS (v1.10.0 Sesjon 1)
// ============================================================
//
// Felles grunnskjelett for alle 17 renderers. Sesjon 3-5 wrapper hver
// renderer med renderPageShell({...}, bodyHtml) — body forblir mest
// uendret, header/verdict/keyStats kommer fra denne foundation-laget.
//
// V2-data-shape (parser-output utvides — beholder v1-felter):
// data.verdict?: 'go'|'go-with-conditions'|'block'|'approved'|'failed'|
// 'allow'|'warning'|'n-a'
// data.keyStats?: Array<{label, value, hint?, modifier?}>
//
// MIGRATIONS v1->v2 i bootstrap (se migrateDataVersion under) utleder
// verdict + keyStats fra v1-felter idempotent for eksisterende state.
const VERDICT_NORMAL = {
'go': 'go', 'godkjent': 'approved', 'approved': 'approved',
'go-with-conditions': 'go-with-conditions', 'conditions': 'go-with-conditions', 'betinget': 'go-with-conditions',
'block': 'block', 'blokkert': 'block', 'forbudt': 'block', 'forbidden': 'block',
'failed': 'failed', 'feilet': 'failed', 'underkjent': 'failed',
'allow': 'allow', 'tillatt': 'allow',
'warning': 'warning', 'advarsel': 'warning',
'n-a': 'n-a', 'na': 'n-a'
};
function normalizeVerdict(raw) {
if (raw == null) return 'n-a';
const k = String(raw).toLowerCase().trim();
return VERDICT_NORMAL[k] || 'n-a';
}
function riskLevelModifier(level) {
const k = String(level || '').toLowerCase();
if (k === 'forbudt' || k === 'forbidden') return 'critical';
if (k === 'høy' || k === 'high') return 'high';
if (k === 'begrenset' || k === 'limited') return 'medium';
if (k === 'minimal' || k === 'low') return 'low';
return undefined;
}
function formatNok(n) {
if (n == null) return '—';
const num = Number(n);
if (!isFinite(num)) return String(n);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return Math.round(num / 1000) + 'k';
return String(num);
}
// Per-archetype default keyStats utledere. Hver tar v2-data, returnerer
// Array<{label, value, hint?, modifier?}>. Tom array hvis archetype ikke
// har et naturlig keyStats-aggregat (transparency, plain markdown).
const KEY_STATS_CONFIG = {
'aiact': function (d) {
return [
{ label: 'RISIKONIVÅ', value: d.risk_level || '—', modifier: riskLevelModifier(d.risk_level) },
{ label: 'ROLLE', value: d.role || '—' },
{ label: 'FORPLIKTELSER', value: (d.obligations || []).length, hint: 'antall' }
];
},
'requirements-list': function (d) {
const items = d.items || [];
const required = items.filter(function (i) { return /påkrev|required/i.test(i.status || ''); }).length;
return [
{ label: 'KRAV', value: items.length },
{ label: 'PÅKREVD', value: required, modifier: required ? 'high' : 'low' }
];
},
'text-document': function () { return []; },
'fria': function (d) {
const rights = d.rights || [];
return [
{ label: 'BERØRTE GRUPPER', value: rights.length },
{ label: 'MITIGERENDE', value: rights.filter(function (r) { return r.mitigation; }).length, hint: 'tiltak' }
];
},
'conformity-checklist': function (d) {
const cl = d.checklist || [];
const passed = cl.filter(function (c) { return /pass|bestått|ok/i.test(c.status || ''); }).length;
return [
{ label: 'KRITERIER', value: cl.length },
{ label: 'BESTÅTT', value: passed, modifier: passed === cl.length ? 'low' : 'medium' },
{ label: 'FRISTER', value: (d.deadlines || []).length, hint: 'kommende' }
];
},
'matrix-risk': function (d) {
const threats = d.threats || [];
const high = threats.filter(function (t) {
const s = String(t.severity || '').toLowerCase();
return s === 'høy' || s === 'high' || s === 'kritisk' || s === 'critical';
}).length;
return [
{ label: 'TRUSLER', value: threats.length },
{ label: 'HØY/KRITISK', value: high, modifier: high ? 'high' : 'low' },
{ label: 'CELLER', value: (d.matrix_cells || []).length, hint: 'i matrise' }
];
},
'matrix-risk-6x5': function (d) {
const findings = d.findings || [];
const dims = d.dimensions || [];
const sum = dims.reduce(function (a, b) { return a + (Number(b.score) || 0); }, 0);
const avg = dims.length ? (sum / dims.length).toFixed(1) : '—';
return [
{ label: 'DIMENSJONER', value: dims.length },
{ label: 'SNITT', value: avg, hint: 'av 5' },
{ label: 'FUNN', value: findings.length, modifier: findings.length > 5 ? 'high' : 'medium' }
];
},
'findings': function (d) {
const f = d.findings || [];
const crit = f.filter(function (x) { return /crit|kritisk/i.test(x.severity || ''); }).length;
return [
{ label: 'FUNN', value: f.length },
{ label: 'KRITISKE', value: crit, modifier: crit ? 'critical' : 'low' }
];
},
'cost-distribution': function (d) {
return [
{ label: 'P50', value: formatNok(d.p50), hint: 'median' },
{ label: 'P90', value: formatNok(d.p90), hint: 'pessimistisk', modifier: 'high' },
{ label: 'KOMPONENTER', value: (d.monthly_breakdown || []).length }
];
},
'capability': function (d) {
const lic = d.licenses || [];
const totalCaps = lic.reduce(function (a, l) { return a + ((l.capabilities || []).length); }, 0);
return [
{ label: 'LISENSER', value: lic.length },
{ label: 'KAPABILITETER', value: totalCaps }
];
},
'phased-plan': function (d) {
const phases = d.phases || [];
const totalWeeks = phases.reduce(function (a, p) { return a + (Number(p.duration_weeks) || 0); }, 0);
const risks = d.risks || [];
return [
{ label: 'FASER', value: phases.length },
{ label: 'VARIGHET', value: totalWeeks || '—', hint: 'uker totalt' },
{ label: 'RISIKOER', value: risks.length, modifier: risks.length > 3 ? 'high' : 'medium' }
];
},
'markdown': function (d) {
const sec = d.sections || [];
return sec.length ? [{ label: 'SEKSJONER', value: sec.length }] : [];
},
'verdict': function (d) {
const km = d.key_metrics || [];
return km.slice(0, 4).map(function (m) {
return {
label: String(m.label || m.name || '').toUpperCase(),
value: m.value != null ? m.value : '—',
hint: m.unit || undefined
};
});
},
'comparison': function (d) {
return [
{ label: 'KANDIDATER', value: (d.subjects || []).length },
{ label: 'DIMENSJONER', value: (d.rows || []).length }
];
}
};
function inferVerdict(data, archetype) {
if (!data) return 'n-a';
// Eksplisitt verdict tar prioritet uansett kilde.
if (data.verdict) return normalizeVerdict(data.verdict);
switch (archetype) {
case 'aiact': {
const lvl = String(data.risk_level || '').toLowerCase();
if (lvl === 'forbudt' || lvl === 'forbidden') return 'block';
if (lvl === 'høy' || lvl === 'high') return 'warning';
if (lvl === 'begrenset' || lvl === 'limited') return 'go-with-conditions';
if (lvl === 'minimal' || lvl === 'low') return 'go';
return 'n-a';
}
case 'matrix-risk':
case 'matrix-risk-6x5': {
const threats = data.threats || data.findings || [];
const hasCritical = threats.some(function (t) { return /crit|kritisk/i.test(t.severity || ''); });
if (hasCritical) return 'block';
if (threats.length) return 'warning';
return 'n-a';
}
case 'fria': {
const rights = data.rights || [];
if (!rights.length) return 'n-a';
const max = rights.reduce(function (a, r) { const v = Number(r.impact) || 0; return v > a ? v : a; }, 0);
if (max >= 4) return 'block';
if (max >= 3) return 'warning';
if (max >= 1) return 'go-with-conditions';
return 'go';
}
case 'conformity-checklist': {
const cl = data.checklist || [];
if (!cl.length) return 'n-a';
const anyFailed = cl.some(function (c) { return /fail|underkjent/i.test(c.status || ''); });
if (anyFailed) return 'failed';
const allPassed = cl.every(function (c) { return /pass|bestått|ok/i.test(c.status || ''); });
if (allPassed) return 'approved';
return 'go-with-conditions';
}
case 'findings': {
const fs = data.findings || [];
if (!fs.length) return 'allow';
const crit = fs.some(function (f) { return /crit|kritisk/i.test(f.severity || ''); });
return crit ? 'block' : 'warning';
}
case 'cost-distribution': {
if (data.p90 != null && data.p50 != null) {
const ratio = Number(data.p90) / Math.max(Number(data.p50), 1);
return ratio > 2 ? 'warning' : 'go';
}
return 'n-a';
}
default:
return 'n-a';
}
}
function inferKeyStats(data, archetype) {
if (!data) return [];
// Eksplisitt keyStats tar prioritet
if (Array.isArray(data.keyStats)) return data.keyStats;
const fn = KEY_STATS_CONFIG[archetype];
if (typeof fn !== 'function') return [];
try {
const out = fn(data);
return Array.isArray(out) ? out : [];
} catch (e) {
return [];
}
}
function renderVerdictPill(verdict) {
const v = String(verdict || 'n-a').toLowerCase();
const labels = {
'go': 'GO',
'go-with-conditions': 'BETINGET',
'block': 'BLOKKERT',
'approved': 'GODKJENT',
'failed': 'UNDERKJENT',
'allow': 'TILLATT',
'warning': 'ADVARSEL',
'n-a': 'IKKE VURDERT'
};
return '<span class="verdict-pill" data-verdict="' + escapeAttr(v) + '">' +
escapeHtml(labels[v] || v.toUpperCase()) +
'</span>';
}
function renderKeyStatsGrid(stats) {
if (!stats || !stats.length) return '';
const items = stats.map(function (s) {
const cls = 'key-stat' + (s.modifier ? ' key-stat--' + escapeAttr(s.modifier) : '');
const hint = s.hint ? '<span class="key-stat__hint">' + escapeHtml(s.hint) + '</span>' : '';
return '<div class="' + cls + '">' +
'<span class="key-stat__label">' + escapeHtml(s.label || '') + '</span>' +
'<span class="key-stat__value">' + escapeHtml(String(s.value)) + '</span>' +
hint +
'</div>';
}).join('');
return '<div class="key-stats">' + items + '</div>';
}
function renderPageShell(opts, bodyHtml) {
opts = opts || {};
const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
const title = '<h1 class="page__title">' + escapeHtml(opts.title || '') + '</h1>';
const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict) : '';
const aside = verdict ? '<div class="page__header-aside">' + verdict + '</div>' : '';
const stats = renderKeyStatsGrid(opts.keyStats);
return (
'<header class="page__header">' +
'<div class="page__header-main">' + eyebrow + title + lede + '</div>' +
aside +
'</header>' +
stats +
(bodyHtml || '')
);
}
// ============================================================
// DATA-VERSION MIGRATION (v1->v2)
// ============================================================
//
// State.dataVersion sporer parser-output-format separat fra
// state.schemaVersion (som sporer state-shape). v1.9.0 produserte
// parser-output uten verdict/keyStats; v1.10.0 utvider med felles
// grunnskjelett-data. Migrasjonen er additive — eksisterende felter
// forblir uendret.
//
// v1_to_v2-handler: itererer projects[].reports[cmdId].parsed; hvis
// verdict eller keyStats mangler, utled fra eksisterende felter via
// inferVerdict + inferKeyStats. Setter state.dataVersion = 2 så
// migrasjonen er idempotent (re-kjøring er no-op).
function migrateDataVersion(state, archetypeFor) {
if (!state) return state;
if (state.dataVersion === 2) return state;
const projects = state.projects || [];
for (let i = 0; i < projects.length; i++) {
const reports = (projects[i] && projects[i].reports) || {};
const ids = Object.keys(reports);
for (let j = 0; j < ids.length; j++) {
const cmdId = ids[j];
const r = reports[cmdId];
if (!r || !r.parsed) continue;
const arche = typeof archetypeFor === 'function' ? archetypeFor(cmdId) : null;
if (!arche) continue;
if (r.parsed.verdict == null) r.parsed.verdict = inferVerdict(r.parsed, arche);
if (!Array.isArray(r.parsed.keyStats)) r.parsed.keyStats = inferKeyStats(r.parsed, arche);
}
}
state.dataVersion = 2;
return state;
}
function defaultArchetypeFor(commandId) {
const cmds = (CATALOG && CATALOG.commands) || [];
for (let i = 0; i < cmds.length; i++) {
if (cmds[i].id === commandId) return cmds[i].report_archetype || null;
}
return null;
}
// Eksponer for tester og fremtidig renderer-iterasjon (Sesjon 3-5)
window.__renderPageShell = renderPageShell;
window.__renderVerdictPill = renderVerdictPill;
window.__renderKeyStatsGrid = renderKeyStatsGrid;
window.__inferVerdict = inferVerdict;
window.__inferKeyStats = inferKeyStats;
window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG;
window.__migrateDataVersion = migrateDataVersion;
window.__defaultArchetypeFor = defaultArchetypeFor;
// === V2_FOUNDATION_END ===
// ---- 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 = '<div class="error-summary"><h3 class="error-summary__heading">Routing-feil</h3><div class="error-summary__body"><p>Mangler parser eller renderer for ' + escapeHtml(cmd.id) + '.</p></div></div>';
return;
}
const result = parser(markdown);
// Topic 2 strategi A: sentralisert _consumer-tildeling i import-flow.
// Respekterer parser-spesifikk verdi (f.eks. parseMatrixRisk → 'ros').
// Renderere kan bruke _consumer for å velge variant-spesifikk markup
// der parser-arketypen er delt mellom flere commands.
if (result && result.ok && result.data && result.data._consumer == null) {
result.data._consumer = cmd.id;
}
slot.innerHTML = '';
if (result.ok) renderer(result.data, slot);
else renderError(result.errors, slot);
// v1.10.0+: persister raw_markdown på aktivt prosjekt så paste-imports
// overlever reload + rehydreres når brukeren navigerer tilbake.
// Skip equal-value writes — set-trap dispatcher uavhengig av verdi-likhet,
// og rehydrate kaller handlePasteImport med eksisterende markdown.
// Uten guarden ville det blitt render-loop.
const project = findProject(store.state.activeProjectId);
if (project && markdown && typeof markdown === 'string' && markdown.trim()) {
if (!project.reports) project.reports = {};
if (!project.reports[commandId]) project.reports[commandId] = { input: {} };
if (project.reports[commandId].raw_markdown !== markdown) {
project.reports[commandId].raw_markdown = markdown;
}
}
}
window.__handlePasteImport = handlePasteImport;
// v1.10.0+: Rehydrer paste-imports fra raw_markdown på aktivt prosjekt.
// Kalles av project surface render etter at tabs/panels er i DOM.
// Filler textareas og kjører handlePasteImport for hver lagret rapport.
function rehydratePasteImports() {
const project = findProject(store.state.activeProjectId);
if (!project || !project.reports) return;
const root = getSurfaceEl('project');
if (!root) return;
Object.keys(project.reports).forEach(function (cmdId) {
const rec = project.reports[cmdId];
if (!rec || !rec.raw_markdown) return;
const ta = root.querySelector('[data-paste-import="' + cmdId + '"]');
if (ta) ta.value = rec.raw_markdown;
// Render visualiseringen — handlePasteImport finner slot via querySelector.
handlePasteImport(cmdId, rec.raw_markdown);
});
}
window.__rehydratePasteImports = rehydratePasteImports;
// ============================================================
// 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.<group>.<field>;
// Proxy-set-trap dispatcher 'change' → throttled writer persisterer
// til IDB. Re-onboard er bare navigate('onboarding') igjen — skjemaet
// pre-fylles automatisk fra eksisterende state.
// v1.10.0: 4 strukturerte (sector, ai_act_role, risk_level, data_classification)
// + 16 fritekst (text/textarea med placeholder). Per R4 i plan-Revisions:
// free-text gir presis virksomhetskontekst som kan settes inn direkte i
// command-prompts uten å tvinge sjabloner som ikke matcher domenet.
const ONBOARDING_SCHEMA = [
{
id: 'organization',
title: 'Virksomhetsprofil',
sub: 'Hvem er dere?',
fields: [
{ id: 'name', label: 'Virksomhetsnavn', type: 'text', required: true,
placeholder: 'f.eks. Bærum kommune, Statens vegvesen, Helse Sør-Øst RHF' },
{ id: 'description', label: 'Kort beskrivelse', type: 'textarea',
placeholder: 'Hva gjør virksomheten? F.eks. "Kommune med 8 000 ansatte, ansvar for skole, helse og byggesak."' },
{ id: 'sector', label: 'Sektor', type: 'select', required: true,
options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
{ id: 'size', label: 'Antall ansatte', type: 'text',
placeholder: 'f.eks. "1 500", "ca. 8 000", "<100"' }
]
},
{
id: 'regulatory',
title: 'Regulatorisk grunnlag',
sub: 'Hvilke krav styrer dere etter, og hvilken AI Act-rolle har dere?',
fields: [
{ id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'textarea',
placeholder: 'f.eks. "GDPR/Personopplysningsloven, Sikkerhetsloven, Forvaltningsloven, Helseregisterloven, Arkivloven"' },
{ id: 'ai_act_role', label: 'EU AI Act-rolle', type: 'select',
options: ['provider', 'deployer', 'distributor', 'importer'] },
{ id: 'risk_level', label: 'EU AI Act risikonivå', type: 'select',
options: ['forbidden', 'high', 'limited', 'minimal'] }
]
},
{
id: 'technology',
title: 'Teknologistack',
sub: 'Hva har dere allerede?',
fields: [
{ id: 'cloud_platform', label: 'Skyplattform', type: 'textarea',
placeholder: 'f.eks. "Azure (Norge Øst), AWS (Stockholm), on-prem datasenter Drammen"' },
{ id: 'license_type', label: 'Lisenstype', type: 'text',
placeholder: 'f.eks. "M365 E5", "Azure Enterprise Agreement", "Power Platform per app"' },
{ id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'textarea',
placeholder: 'f.eks. "Azure OpenAI (GPT-4o, embedding), Copilot for M365, AI Builder, Azure AI Search"' }
]
},
{
id: 'security',
title: 'Sikkerhet og compliance',
sub: 'Hvilke data og praksiser 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: 'text',
placeholder: 'f.eks. "Kun Norge", "EU/EØS", "Norden", "Ingen spesifikke krav"' },
{ id: 'dpia_practice', label: 'DPIA-praksis', type: 'textarea',
placeholder: 'Hvordan utløses og gjennomføres DPIA? F.eks. "Sentralt personvernombud, mal etter Datatilsynet, halvårlig revisjon."' },
{ id: 'certifications', label: 'Sertifiseringer / rammeverk', type: 'textarea',
placeholder: 'f.eks. "ISO 27001, Digdir Trygg-pilot, NSM grunnprinsipper for IKT-sikkerhet"' }
]
},
{
id: 'architecture',
title: 'Arkitekturbeslutninger',
sub: 'Hvor vil dere?',
fields: [
{ id: 'preferred_platform', label: 'Foretrukket AI-plattform', type: 'text',
placeholder: 'f.eks. "Azure AI Foundry", "Copilot Studio", "Power Platform/AI Builder", "Ikke bestemt"' },
{ id: 'integration_needs', label: 'Integrasjonsbehov', type: 'textarea',
placeholder: 'Eksisterende systemer som trenger AI-integrasjon. F.eks. "M365, SAP S/4, fagsystem KOMTEK, REST API mot folkeregister."' },
{ id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'text',
placeholder: 'f.eks. "2 MNOK", "500k-2M", "Ikke definert"' }
]
},
{
id: 'business',
title: 'Forretningsreferanser',
sub: 'Hvordan styrer dere?',
fields: [
{ id: 'governance_model', label: 'Styringsmodell for AI', type: 'textarea',
placeholder: 'Hvem eier AI-beslutninger? F.eks. "Sentralt AI-råd ledes av digitaliseringsdirektør, beslutninger eskalerer til CIO."' },
{ id: 'doc_format_preferences', label: 'Dokumentformat', type: 'text',
placeholder: 'f.eks. "Markdown + PDF", "Confluence", "SharePoint Wiki", "Word"' },
{ id: 'reference_architecture', label: 'Referansearkitektur', type: 'textarea',
placeholder: 'Eksisterende prinsipper, lenker til wiki/docs. F.eks. "TOGAF-tilpasset, ref Confluence /arch."' }
]
}
];
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 ? '<span class="required-mark" aria-hidden="true">*</span>' : '';
const labelHtml = '<label for="' + fieldId + '" class="field-label">' + escapeHtml(field.label) + requiredMark + '</label>';
const placeholderAttr = field.placeholder ? ' placeholder="' + escapeAttr(field.placeholder) + '"' : '';
let inputHtml = '';
if (field.type === 'text') {
inputHtml = '<input type="text" id="' + fieldId + '" ' + dataAttrs + placeholderAttr + ' value="' + escapeAttr(value || '') + '" class="input">';
} else if (field.type === 'textarea') {
inputHtml = '<textarea id="' + fieldId + '" ' + dataAttrs + placeholderAttr + ' class="textarea" rows="3">' + escapeHtml(value || '') + '</textarea>';
} else if (field.type === 'select') {
const opts = ['<option value="">(velg)</option>'].concat(field.options.map(function (o) {
const sel = (o === value) ? ' selected' : '';
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
})).join('');
inputHtml = '<select id="' + fieldId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
} 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 '<label class="checkbox-row" for="' + cbId + '"><input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-multi-option="' + escapeAttr(o) + '"' + checked + '><span>' + escapeHtml(o) + '</span></label>';
}).join('');
inputHtml = '<fieldset class="multi-select" aria-labelledby="' + fieldId + '-legend"><legend id="' + fieldId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' + opts + '</fieldset>';
} else if (field.type === 'boolean') {
const checked = value === true ? ' checked' : '';
inputHtml = '<label class="checkbox-row" for="' + fieldId + '"><input type="checkbox" id="' + fieldId + '" ' + dataAttrs + checked + '><span>' + escapeHtml(field.label) + '</span></label>';
}
return '<div class="field-row" data-field-row="' + escapeAttr(path) + '">' + labelHtml + inputHtml + '</div>';
}
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 (
'<button type="button" class="fp-step" data-state="' + state + '" data-action="onboarding-goto-group" data-group="' + escapeAttr(p.id) + '">' +
'<span class="fp-step__num" aria-hidden="true">' + numHtml + '</span>' +
'<span>' +
'<span class="fp-step__name">' + escapeHtml(p.title) + '</span>' +
'<span class="fp-step__progress">' +
'<span class="fp-step__bar"><span class="fp-step__bar-fill" style="width:' + pct + '%"></span></span>' +
'<span>' + p.filled + '/' + p.total + '</span>' +
'</span>' +
'</span>' +
'</button>'
);
}).join('');
const sidebar = (
'<aside class="form-progress" aria-label="Onboarding-fremdrift">' +
'<div class="form-progress__autosave">' +
'<span class="form-progress__autosave-dot"></span>' +
'<span>Lagres automatisk</span>' +
'</div>' +
'<div class="form-progress__steps">' + sidebarSteps + '</div>' +
'<div class="form-progress__remaining">' +
'<span>Utfylt</span>' +
'<span>' + totalFilled + '/' + totalAll + '</span>' +
'</div>' +
'</aside>'
);
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 (
'<section class="expansion" aria-expanded="' + expandedAttr + '" data-onboarding-group="' + escapeAttr(g.id) + '">' +
'<button type="button" class="expansion__head" data-action="onboarding-toggle-group">' +
'<span class="expansion__title">' +
'<span class="expansion__title-main">' + escapeHtml(g.title) + '</span>' +
'<span class="expansion__title-sub">' + escapeHtml(g.sub) + ' — ' + p.filled + '/' + p.total + '</span>' +
'</span>' +
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="expansion__body">' +
'<div class="expansion__body-inner">' +
'<div class="onboarding-fields">' + fieldsHtml + '</div>' +
'</div>' +
'</div>' +
'</section>'
);
}).join('');
const errorSummary = (
'<div class="error-summary" data-onboarding-errors hidden role="alert" aria-live="polite">' +
'<h2 class="error-summary__heading">Noen felter må fylles ut</h2>' +
'<div class="error-summary__body">' +
'<ul class="error-summary__list" data-onboarding-error-list></ul>' +
'</div>' +
'</div>'
);
const orgName = store.state.shared.organization && store.state.shared.organization.name;
const skipBackBtn = orgName
? '<button type="button" class="btn btn--ghost" data-action="onboarding-cancel">Tilbake til hjem</button>'
: '';
const hasDemoBlock = !!document.getElementById('demo-state-v1');
const demoBtn = hasDemoBlock
? '<button type="button" class="btn btn--secondary" data-action="load-demo" title="Hopper over onboarding og laster ett ferdig demo-prosjekt med alle 17 rapport-typer pre-importert">Last inn demo-data</button>'
: '';
const actionBar = (
'<div class="onboarding-actions">' +
'<button type="button" class="btn btn--primary" data-action="onboarding-save">Lagre og fortsett</button>' +
skipBackBtn +
demoBtn +
'<span class="onboarding-help">Du kan endre alt senere via Re-onboard. Demo-data overskriver eksisterende state.</span>' +
'</div>'
);
const onboardingShell = renderPageShell({
eyebrow: 'ONBOARDING',
title: 'Bli kjent med oss',
lede: 'Oppgi virksomhetskontekst slik at vi kan tilpasse arkitekturråd til din situasjon. 20 felles felter gjenbrukes på tvers av alle commands.',
verdict: 'n-a',
keyStats: []
},
'<div class="onboarding-layout">' +
sidebar +
'<div class="onboarding-main">' +
errorSummary +
'<div class="onboarding-groups">' + groupsHtml + '</div>' +
actionBar +
'</div>' +
'</div>'
);
root.innerHTML = (
'<div class="app-shell">' +
onboardingShell +
'</div>'
);
}
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 '<li class="error-summary__item"><a href="#" class="error-summary__link" data-action="onboarding-focus-error" data-error-target="' + escapeAttr(e.path) + '">' + escapeHtml(e.label) + ' — ' + escapeHtml(e.message) + '</a></li>';
}).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');
};
// v1.10.0+: Last inn demo-state fra inline JSON-blokken.
// Bygges av scripts/build-demo-state.mjs ved hver release. Erstatter all
// eksisterende state med ferdig demo-prosjekt + 17 pre-importerte rapporter.
ACTIONS['load-demo'] = function () {
const node = document.getElementById('demo-state-v1');
if (!node) {
console.warn('[playground v3] demo-state-v1 inline JSON ikke funnet — kjør node scripts/build-demo-state.mjs');
return;
}
let demo;
try {
demo = JSON.parse(node.textContent || '{}');
} catch (e) {
console.warn('[playground v3] demo-state-v1 JSON parse feilet:', e);
return;
}
// Erstatt top-level state-grener via Proxy-mutasjon for reactivity.
// schemaVersion + dataVersion bevares fra demo-state for migrasjons-konsistens.
['schemaVersion', 'dataVersion', 'shared', 'projects', 'activeProjectId',
'activeSurface', 'preferences'].forEach(function (k) {
if (demo[k] !== undefined) store.state[k] = demo[k];
});
// Reset interne UI-state-variabler så project-render starter i 'rapporter'-tab.
currentProjectTab = 'regulatory';
currentProjectScreen = 'rapporter';
navigate(demo.activeSurface || 'project');
};
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å <html>, 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['project-screen'] = function (ev, el) {
const screen = el.dataset.screen;
if (!screen) return;
currentProjectScreen = screen;
// Toggle aria-current på .tab-list-knappene + [hidden] på .tab-panel-paneler
// uten full re-render (bevarer evt textarea-input i panels).
const root = getSurfaceEl('project');
if (!root) return;
const tabs = root.querySelectorAll('.tab-list .tab');
tabs.forEach(function (t) {
t.setAttribute('aria-current', t.dataset.screen === screen ? 'true' : 'false');
});
const screens = root.querySelectorAll('.tab-panel[data-screen-id]');
screens.forEach(function (s) {
if (s.dataset.screenId === screen) s.removeAttribute('hidden');
else s.setAttribute('hidden', '');
});
};
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 header = el.closest('.app-header');
if (!header) return;
const input = header.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 <body>
// så DOM er allerede klar.
bootstrap().catch(function (err) {
console.error('[playground v3] bootstrap failed:', err);
});
})();
</script>
</body>
</html>