ktg-plugin-marketplace/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
Kjell Tore Guttormsen 9664bf1b1c feat(ms-ai-architect): release v1.9.0 with playground v3 + screenshot suite
Version bump: v1.8.0 -> v1.9.0 (minor — plugin API surface unchanged).

Version sync:
- .claude-plugin/plugin.json (canonical), README.md badge,
  CHANGELOG.md (full v1.9.0 entry with playground v3 architecture,
  validation suite, A11Y artifacts, SemVer rationale),
  marketplace root README.md listing.

Screenshot suite (new):
- scripts/screenshots/capture-playground.py — Playwright Python automation
  that opens playground from file://, populates __store with Statens vegvesen
  ANPR demo data, navigates each surface, paste-imports fixtures, scrolls to
  the relevant report-slot, and saves viewport screenshots.
- 6 PNG screenshots in playground/screenshots/ covering: onboarding (18/18
  filled), home (3 projects), catalog (24 commands across 5 expansion groups),
  classify pyramid (high-risk Annex III), ROS 5x5 matrix + 7-dim radar,
  cost P10/P50/P90 distribution.

Doc updates (3 levels per repo policy):
- Plugin README: new "Screenshots" subsection embeds all 6 with description
  columns, plus reproduce command.
- Plugin CLAUDE.md: new "Screenshot-suite (v1.9.0)" subsection documenting
  the automation, demo-state seeding, and re-run trigger conditions.
- Marketplace root README: ms-ai-architect listing now mentions the
  screenshot suite + reproduce command.

Reproduce screenshots: python3 scripts/screenshots/capture-playground.py.

Notes:
- Light-mode tokens are not in the vendored design-system yet. The toggle
  swaps data-theme + label correctly (Step 13 mechanics intact), but the
  CSS palette only ships dark. Captured dark-mode only; light-mode capture
  re-enables when shared/playground-design-system gains [data-theme="light"]
  overrides.
- Local CSS fix in playground HTML: added `[hidden] { display: none !important; }`
  in the inline app-shell <style> block. The vendored .error-summary rule
  sets display: flex which overrode HTML's [hidden] default, leaking the
  onboarding error banner on cold start. Plugin-local for now; a proper
  fix belongs in shared/playground-design-system/components-tier3.css.

Verified post-bump:
- bash tests/validate-plugin.sh -> 215/215 PASS
- bash tests/run-e2e.sh --playground -> 240/240 PASS
2026-05-03 20:40:07 +02:00

3872 lines
186 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 (Step 13). Må kjøre før stylesheets parses for å unngå
flash-of-wrong-theme (FOUC). Leser ms-ai-architect-theme fra localStorage
og overstyrer <html data-theme="..."> før .css evaluerer.
Wrappes i try/catch — file:// + privatmodus kan blokkere localStorage. -->
<script>
(function () {
try {
var saved = localStorage.getItem('ms-ai-architect-theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.setAttribute('data-theme', saved);
}
} catch (e) { /* localStorage utilgjengelig — behold default fra HTML-attributtet */ }
})();
</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 { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-5); }
.app-shell--wide { max-width: 1400px; }
/* Topbar — vises på alle surfaces unntatt onboarding (uten projekt-kontekst) */
.topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-5); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface); position: sticky; top: 0; z-index: 10; }
.topbar__brand { display: flex; align-items: center; gap: var(--space-2); font-weight: var(--font-weight-semibold); }
.topbar__brand-mark { width: 28px; height: 28px; border-radius: var(--radius-sm); background: var(--color-primary-500); color: var(--color-text-on-primary); display: inline-flex; align-items: center; justify-content: center; font-family: var(--font-family-mono); font-weight: var(--font-weight-bold); font-size: 13px; }
.topbar__nav { display: flex; gap: var(--space-2); align-items: center; }
.topbar__crumb { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
.topbar__crumb a { cursor: pointer; }
/* 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; }
.field-row { display: flex; flex-direction: column; gap: 6px; }
.field-label { font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-primary); }
.field-help { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.multi-select { display: flex; flex-direction: column; gap: 4px; border: 0; padding: 0; margin: 0; }
.checkbox-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: var(--font-size-sm); padding: 4px 0; }
.checkbox-row input { margin: 0; }
.required-mark { color: var(--color-severity-critical); margin-left: 2px; }
.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); }
.command-card { background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.command-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
.command-card__title { font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); margin: 0; }
.command-card__desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 4px 0 0; }
.command-card__id { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.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; }
.catalog-card { background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.catalog-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
.catalog-card__title { font-size: var(--font-size-md); margin: 0; font-weight: var(--font-weight-semibold); }
.catalog-card__desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 4px 0 0; }
.catalog-card__pill { padding: 2px 8px; background: var(--color-bg-soft); color: var(--color-text-secondary); border-radius: var(--radius-sm); font-size: 10px; font-weight: var(--font-weight-medium); flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.04em; }
.catalog-card__meta { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
.catalog-card__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); font-family: var(--font-family-mono); }
.catalog-card__actions { display: flex; gap: var(--space-2); margin-top: auto; padding-top: var(--space-2); }
.catalog-tool-notice { padding: var(--space-2) var(--space-3); background: var(--color-bg-soft); border-left: 3px solid var(--color-primary-500); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--color-text-secondary); }
</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>
<!--
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;
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,
shared: envelope.shared || INITIAL_STATE.shared,
projects: envelope.projects || [],
activeProjectId: envelope.activeProjectId || null,
activeSurface: envelope.activeSurface || 'home',
preferences: envelope.preferences || INITIAL_STATE.preferences
});
// 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;
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();
}
// Topbar — gjenbrukes på home, catalog, project. Onboarding viser ingen topbar
// (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 crumbHtml = (orgName || crumb)
? '<span class="topbar__crumb">' + (orgName ? escapeHtml(orgName) : '') + (orgName && crumb ? ' · ' : '') + (crumb || '') + '</span>'
: '';
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="topbar">' +
'<div class="topbar__brand">' +
'<span class="topbar__brand-mark" aria-hidden="true">M</span>' +
'<span>ms-ai-architect</span>' +
crumbHtml +
'</div>' +
'<nav class="topbar__nav" 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>' +
'</nav>' +
'</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;
}
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 18 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 heroHtml = (
'<section class="home-hero">' +
'<h1>' + (orgName ? 'Hei, ' + escapeHtml(orgName) : 'ms-ai-architect') + '</h1>' +
'<p>' + (orgName
? 'Velg hvor du vil starte. Felles state er aktiv og forhåndsutfyller skjemaer.'
: 'Single-file arkitektur-rådgivning for Microsoft AI-stakken. Start med onboarding for å aktivere felles state.'
) + '</p>' +
'</section>'
);
const projectsSection = (
'<section class="home-projects">' +
'<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>'
);
root.innerHTML = (
renderTopbar('Hjem') +
'<div class="app-shell">' +
heroHtml +
tracksHtml +
projectsSection +
'</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';
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';
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) {
const titleHtml = (
'<div class="command-card__head">' +
'<div>' +
'<h3 class="command-card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="command-card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
'<span class="command-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="command-card" 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="command-card" 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);
const scenarioChips = (project.scenarios || []).map(function (sid) {
const s = SCENARIOS.find(function (x) { return x.id === sid; });
return '<span class="project-header__chip">' + escapeHtml(s ? s.name : sid) + '</span>';
}).join('');
const dateChip = '<span class="project-header__chip">opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + '</span>';
const progressChip = '<span class="project-header__chip">' + reportFilled + '/' + reportTotal + ' rapporter</span>';
const headerHtml = (
'<header class="project-header">' +
'<div class="project-header__top">' +
'<div>' +
'<h1 class="project-header__title">' + escapeHtml(project.name) + '</h1>' +
(project.description ? '<p style="color: var(--color-text-secondary); margin-top: var(--space-2); max-width: 70ch;">' + escapeHtml(project.description) + '</p>' : '') +
'</div>' +
'<div style="display:flex; gap: var(--space-2); flex-shrink: 0;">' +
'<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>' +
'</div>' +
'<div class="project-header__meta">' + dateChip + progressChip + scenarioChips + '</div>' +
'</header>'
);
// Tabs per CATALOG.categories
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('');
root.innerHTML = (
renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
'<div class="app-shell app-shell--wide">' +
headerHtml +
tabsHtml +
panelsHtml +
'</div>'
);
}
// ============================================================
// 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="catalog-card__pill">Verktøy</span>'
: '<span class="catalog-card__pill">Rapport</span>';
const hintHtml = cmd.argument_hint
? '<span class="catalog-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="catalog-card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
'<div class="catalog-card__head">' +
'<div>' +
'<h3 class="catalog-card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="catalog-card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
pill +
'</div>' +
'<div class="catalog-card__meta">' +
'<span class="command-card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
hintHtml +
'</div>' +
verktoyNotice +
'<div class="catalog-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) + '»' : '');
root.innerHTML = (
renderTopbar('Katalog') +
'<div class="app-shell app-shell--wide">' +
'<header class="catalog-header">' +
'<h1>Command-katalog</h1>' +
'<p>24 commands gruppert i 5 kategorier. Åpne skjema for å bygge en pipeline-streng som kopieres til terminalen og kjøres med Claude Code.</p>' +
'</header>' +
'<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>'
);
}
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="catalog-card__desc" style="margin-top: var(--space-2);">' + escapeHtml(cmd.description) + '</p>' +
'<span class="command-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];
const checklist = checklistTbl.rows.map(function (row) {
return {
requirement: row[reqKey] || '',
status: (row[statusKey] || '').toLowerCase().trim(),
evidence: row[evidKey] || ''
};
});
const deadlinesTbl = parseTable(md, /##\s*Frister/i);
const deadlines = deadlinesTbl ? deadlinesTbl.rows.map(function (row) {
const dateKey = deadlinesTbl.headers.find(function (h) { return /dato|date/i.test(h); }) || deadlinesTbl.headers[0];
const mileKey = deadlinesTbl.headers.find(function (h) { return /milepæl|milestone/i.test(h); }) || deadlinesTbl.headers[1];
const stKey = deadlinesTbl.headers.find(function (h) { return /status/i.test(h); }) || deadlinesTbl.headers[2];
return {
date: row[dateKey] || '',
milestone: row[mileKey] || '',
status: (row[stKey] || '').toLowerCase().trim()
};
}) : [];
return { ok: true, data: { checklist: checklist, deadlines: deadlines } };
}
function parseMatrixRisk(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const matrixTbl = parseTable(md, /Risikomatrise.*5/i) || parseTable(md);
if (!matrixTbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen risikomatrise funnet' }] };
const labelKey = matrixTbl.headers[0];
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
const matrix_cells = matrixTbl.rows.map(function (row) {
return {
label: row[labelKey] || '',
prob: intOrZero(row[sannKey] || '0'),
cons: intOrZero(row[konsKey] || '0'),
score: intOrZero(row[scoreKey] || '0')
};
});
const threatsTbl = parseTable(md, /##\s*Trusler/i);
const threats = threatsTbl ? threatsTbl.rows.map(function (row) {
const idKey = threatsTbl.headers[0];
const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1];
const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
return {
id: row[idKey] || '',
description: row[descKey] || '',
severity: (row[sevKey] || '').toLowerCase().trim(),
mitigation: row[mitKey] || ''
};
}) : [];
const radarTbl = parseTable(md, /Radar.akser/i);
const radar_axes = radarTbl ? radarTbl.rows.map(function (row) {
const akseKey = radarTbl.headers.find(function (h) { return /akse|axis/i.test(h); }) || radarTbl.headers[0];
const scKey = radarTbl.headers.find(function (h) { return /score/i.test(h); }) || radarTbl.headers[1];
return {
name: row[akseKey] || '',
score: intOrZero(row[scKey] || '0')
};
}) : null;
return { ok: true, data: { matrix_cells: matrix_cells, threats: threats, radar_axes: radar_axes } };
}
function parseMatrixRisk6x5(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const dimsTbl = parseTable(md, /Score per dimensjon/i);
if (!dimsTbl) return { ok: false, errors: [{ section: 'dimensions', reason: 'Ingen dimensjon-tabell funnet' }] };
const dimNameKey = dimsTbl.headers.find(function (h) { return /dimensjon/i.test(h); }) || dimsTbl.headers[0];
const dimScoreKey = dimsTbl.headers.find(function (h) { return /score/i.test(h); }) || dimsTbl.headers[1];
const dimVurdKey = dimsTbl.headers.find(function (h) { return /vurdering/i.test(h); });
const dimensions = dimsTbl.rows.map(function (row) {
return {
name: row[dimNameKey] || '',
score: intOrZero(row[dimScoreKey] || '0'),
assessment: row[dimVurdKey] || ''
};
});
const matrixTbl = parseTable(md, /Risikomatrise.*6/i);
const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) {
const labelKey = matrixTbl.headers[0];
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
return {
label: row[labelKey] || '',
prob: intOrZero(row[sannKey] || '0'),
cons: intOrZero(row[konsKey] || '0'),
score: intOrZero(row[scoreKey] || '0')
};
}) : [];
const findingsTbl = parseTable(md, /##\s*Funn/i);
const findings = findingsTbl ? findingsTbl.rows.map(function (row) {
const idKey = findingsTbl.headers[0];
const sevKey = findingsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const locKey = findingsTbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
const recKey = findingsTbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
return {
id: row[idKey] || '',
severity: (row[sevKey] || '').toLowerCase().trim(),
location: row[locKey] || '',
recommendation: row[recKey] || ''
};
}) : [];
return {
ok: true,
data: {
dimensions: dimensions,
matrix_cells: matrix_cells,
findings: findings,
scores: dimensions.map(function (d) { return d.score; })
}
};
}
function parseFindings(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const tbl = parseTable(md, /##\s*Funn/i) || parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen funn-tabell funnet' }] };
const idKey = tbl.headers[0];
const sevKey = tbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const locKey = tbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
const recKey = tbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
const findings = tbl.rows.map(function (row) {
return {
id: row[idKey] || '',
severity: (row[sevKey] || '').toLowerCase().trim(),
location: row[locKey] || '',
recommendation: row[recKey] || ''
};
});
return { ok: true, data: { findings: findings } };
}
function parseCostDistribution(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const distTbl = parseTable(md, /Distribusjon/i);
if (!distTbl) return { ok: false, errors: [{ section: 'distribution', reason: 'Ingen distribusjons-tabell funnet' }] };
const persKey = distTbl.headers.find(function (h) { return /persentil|percentile/i.test(h); }) || distTbl.headers[0];
const monthlyKey = distTbl.headers.find(function (h) { return /månedlig|monthly/i.test(h); }) || distTbl.headers[1];
const yearlyKey = distTbl.headers.find(function (h) { return /årlig|yearly/i.test(h); });
let p10 = null, p50 = null, p90 = null;
distTbl.rows.forEach(function (row) {
const monthly = intOrZero(row[monthlyKey] || '0');
const yearly = yearlyKey ? intOrZero(row[yearlyKey] || '0') : null;
const entry = { monthly: monthly, yearly: yearly };
const tag = (row[persKey] || '').toUpperCase();
if (/P10|P\.10|P 10/.test(tag)) p10 = entry;
else if (/P50|P\.50|P 50/.test(tag)) p50 = entry;
else if (/P90|P\.90|P 90/.test(tag)) p90 = entry;
});
const monthlyTbl = parseTable(md, /Månedlig fordeling/i);
const monthly_breakdown = monthlyTbl ? monthlyTbl.rows.map(function (row) {
const compKey = monthlyTbl.headers[0];
const costKey = monthlyTbl.headers[1];
return {
component: row[compKey] || '',
cost: intOrZero(row[costKey] || '0')
};
}) : [];
const tcoTbl = parseTable(md, /TCO/i);
const tco_table = tcoTbl ? tcoTbl.rows : [];
return {
ok: true,
data: {
p10: p10, p50: p50, p90: p90,
monthly_breakdown: monthly_breakdown,
tco_table: tco_table,
tco_headers: tcoTbl ? tcoTbl.headers : []
}
};
}
function parseCapabilityMatrix(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const tbl = parseTable(md, /##\s*Matrise/i) || parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen matrise funnet' }] };
const capKey = tbl.headers[0];
const licenseNames = tbl.headers.slice(1);
const licenses = licenseNames.map(function (name) {
return { name: name, capabilities: [] };
});
tbl.rows.forEach(function (row) {
const capName = row[capKey];
licenseNames.forEach(function (licName, i) {
licenses[i].capabilities.push({
name: capName,
status: (row[licName] || '').toLowerCase().trim()
});
});
});
return { ok: true, data: { licenses: licenses } };
}
function parsePhasedPlan(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const phases = [];
const lines = md.split(/\r?\n/);
let current = null;
let bucket = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const phaseMatch = /^###\s+(?:Fase\s+\d+\s*[—-]\s*)?(.+?)\s*(?:\(.*\))?\s*$/i.exec(line.trim());
const isH3 = /^###\s+/.test(line);
const isH2 = /^##\s+/.test(line) && !isH3;
if (isH3 && phaseMatch) {
if (current) phases.push(current);
current = {
name: phaseMatch[1].trim(),
milestones: [],
success_criteria: [],
duration_weeks: null
};
bucket = null;
continue;
}
if (isH2) {
if (current) { phases.push(current); current = null; }
bucket = null;
continue;
}
if (!current) continue;
const trimmed = line.trim();
const durMatch = /^Varighet:\s*(\d+)\s*uke/i.exec(trimmed);
if (durMatch) {
current.duration_weeks = parseInt(durMatch[1], 10);
continue;
}
if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; }
if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; }
const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed);
if (bulletMatch && bucket && current[bucket]) {
current[bucket].push(bulletMatch[1].trim());
}
}
if (current) phases.push(current);
const risksTbl = parseTable(md, /##\s*Risiko/i);
const risks = risksTbl ? risksTbl.rows.map(function (row) {
const risikoKey = risksTbl.headers[0];
const sannKey = risksTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
const konsKey = risksTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
const tiltakKey = risksTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
return {
risk: row[risikoKey] || '',
probability: row[sannKey] || '',
consequence: row[konsKey] || '',
mitigation: row[tiltakKey] || ''
};
}) : [];
if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] };
return { ok: true, data: { phases: phases, risks: risks } };
}
function parseMarkdown(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const titleMatch = /^#\s+(.+)$/m.exec(md);
const title = titleMatch ? titleMatch[1].trim() : '';
const sections = parseSections(md);
// Frontmatter-style fields (Status, Date, Deciders) — typisk i ADR
const status = extractField(md, 'Status') || '';
const date = extractField(md, 'Date') || extractField(md, 'Dato') || '';
const deciders = extractField(md, 'Deciders') || extractField(md, 'Beslutningstakere') || '';
return { ok: true, data: { title: title, sections: sections, raw: md, status: status, date: date, deciders: deciders } };
}
function parseVerdict(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const verdictRaw = extractField(md, 'Verdict') || '';
const verdict = verdictRaw.toLowerCase().trim();
const sub = extractField(md, 'Sub') || '';
const sections = parseSections(md);
const ratSec = sections.find(function (s) { return /rationale|begrunnelse/i.test(s.heading); });
const rationale = ratSec ? ratSec.body : '';
const metricsTbl = parseTable(md, /Key Metrics|Nøkkelmetrikker/i);
const key_metrics = metricsTbl ? metricsTbl.rows : [];
const metrics_headers = metricsTbl ? metricsTbl.headers : [];
const nextSec = sections.find(function (s) { return /next steps|neste steg/i.test(s.heading); });
const next_steps = [];
if (nextSec) {
nextSec.body.split(/\r?\n/).forEach(function (line) {
const m = /^[-*]\s+(.+)$/.exec(line.trim());
if (m) next_steps.push(m[1].trim());
});
}
if (!verdict) return { ok: false, errors: [{ section: 'verdict', reason: 'Fant ikke "Verdict:"-linje' }] };
return {
ok: true,
data: {
verdict: verdict,
sub: sub,
rationale: rationale,
key_metrics: key_metrics,
metrics_headers: metrics_headers,
next_steps: next_steps
}
};
}
function parseComparison(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const subject1 = extractField(md, 'Subject 1') || '';
const subject2 = extractField(md, 'Subject 2') || '';
const tbl = parseTable(md, /##\s*Sammenligning|##\s*Comparison/i) || parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen sammenligningstabell funnet' }] };
const aspectKey = tbl.headers[0];
const v1Key = tbl.headers[1];
const v2Key = tbl.headers[2];
const winnerKey = tbl.headers[3];
const subjects = [subject1 || v1Key || '', subject2 || v2Key || ''];
const rows = tbl.rows.map(function (row) {
return {
aspect: row[aspectKey] || '',
value1: row[v1Key] || '',
value2: row[v2Key] || '',
winner: winnerKey ? (row[winnerKey] || '') : ''
};
});
return { ok: true, data: { subjects: subjects, rows: rows } };
}
// ---- PARSERS routing-objekt ----
const PARSERS = {
'aiact': parseAiAct,
'requirements-list': parseRequirements,
'text-document': parseTextDocument,
'fria': parseFria,
'conformity-checklist': parseConformityChecklist,
'matrix-risk': parseMatrixRisk,
'matrix-risk-6x5': parseMatrixRisk6x5,
'findings': parseFindings,
'cost-distribution': parseCostDistribution,
'capability': parseCapabilityMatrix,
'phased-plan': parsePhasedPlan,
'markdown': parseMarkdown,
'verdict': parseVerdict,
'comparison': parseComparison
};
// Eksponer for Verify-asserts og Step 12.
window.__PARSERS = PARSERS;
window.__parseTable = parseTable;
window.__parseSections = parseSections;
window.__extractField = extractField;
// ============================================================
// REPORT RENDERERS (Step 12)
// ============================================================
//
// 17 renderers per kanonisk archetype-routing-tabell. Hver renderer
// tar parsed data + slot DOM-element, og fyller slot.innerHTML med
// markup som matcher design-system BEM-klasser (.pyramide, .matrix,
// .findings, .rights-matrix, .capability-matrix, .distribution,
// .verdict-block, .pipeline-cockpit, .diff, .aiact-timeline).
//
// Routing: RENDERERS[command.renderer] for oppslag i handlePasteImport
// (under). Verktøy-commands (produces_report=false) får ingen renderer.
// ---- Felles helpers ----
function renderEmptyState() {
return '<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)' },
{ id: 'high', label: 'Høyrisiko (Art. 6 + Annex III)' },
{ id: 'limited', label: 'Begrenset risiko (Art. 50)' },
{ id: 'minimal', label: 'Minimal risiko' }
];
const tiersHtml = tiers.map(function (t) {
const active = (t.id === activeTier);
const ariaCurrent = active ? ' aria-current="true"' : '';
const marker = active ? ' <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 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>';
slot.innerHTML = '<div class="pyramide">' + tiersHtml + '</div>' + meta + obligationsHtml;
}
function renderRequirements(data, slot) {
const sevForStatus = function (status) {
const s = (status || '').toLowerCase();
if (s === 'met') return 'low';
if (s === 'partial') return 'medium';
if (s === 'missing') return 'critical';
return 'info';
};
const items = (data.items || []).map(function (it, idx) {
return '<li class="findings__item" data-status="' + escapeAttr(it.status || '') + '">' +
'<span class="findings__item-severity-dot" data-severity="' + escapeAttr(sevForStatus(it.status)) + '"></span>' +
'<span class="findings__item-id">R-' + String(idx + 1).padStart(2, '0') + '</span>' +
'<span class="findings__item-title">' + escapeHtml(it.requirement) + '</span>' +
'<span class="findings__item-meta">Kilde: ' + escapeHtml(it.source_article || '—') + ' · Status: ' + escapeHtml(it.status || '—') + '</span>' +
'</li>';
}).join('');
slot.innerHTML =
'<div class="findings">' +
'<div class="findings__list">' +
'<div class="findings__group">' +
'<div class="findings__group-header"><span>Krav</span><span>' + (data.items || []).length + '</span></div>' +
'<ul class="findings__items">' + items + '</ul>' +
'</div>' +
'</div>' +
'</div>';
}
function renderTransparency(data, slot) {
const sectionsHtml = (data.sections || []).map(function (s) {
return '<section><h2>' + escapeHtml(s.heading) + '</h2><p>' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</p></section>';
}).join('');
slot.innerHTML = '<article class="report-doc">' + sectionsHtml + '</article>';
}
function renderFria(data, slot) {
const headerHtml =
'<div class="rights-matrix__head">' +
'<div class="rights-matrix__head-cell rights-matrix__head-cell--name">Rettighet</div>' +
'<div class="rights-matrix__head-cell">Impact (0-5)</div>' +
'<div class="rights-matrix__head-cell">Tiltak</div>' +
'</div>';
const rowsHtml = (data.rights || []).map(function (r) {
return '<div class="rights-matrix__row">' +
'<div class="rights-matrix__name">' + escapeHtml(r.name) + '</div>' +
'<div class="rights-matrix__cell" data-impact="' + escapeAttr(String(r.impact)) + '">' + r.impact + '</div>' +
'<div class="rights-matrix__name"><div class="rights-matrix__name-meta">' + escapeHtml(r.mitigation) + '</div></div>' +
'</div>';
}).join('');
slot.innerHTML = '<div class="rights-matrix" style="grid-template-columns: 1fr 80px 2fr;">' + headerHtml + rowsHtml + '</div>';
}
function renderConformity(data, slot) {
const stateOf = function (status) {
const s = (status || '').toLowerCase();
if (s === 'passed' || s === 'met' || s === 'done') return 'passed';
if (s === 'active' || s === 'partial' || s === 'in-progress') return 'active';
return 'upcoming';
};
const dlList = data.deadlines || [];
let timelineHtml = '';
if (dlList.length) {
const milestones = dlList.map(function (d, i) {
const left = ((i + 1) / (dlList.length + 1)) * 100;
return '<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 =
'<div class="aiact-timeline">' +
'<div class="aiact-timeline__track">' +
'<div class="aiact-timeline__progress" style="width: 0%"></div>' +
milestones +
'</div>' +
'</div>';
}
const sevForStatus = function (status) {
const s = (status || '').toLowerCase();
if (s === 'met') return 'low';
if (s === 'partial') return 'medium';
if (s === 'missing') return 'critical';
return 'info';
};
const items = (data.checklist || []).map(function (it, idx) {
return '<li class="findings__item" data-status="' + escapeAttr(it.status) + '">' +
'<span class="findings__item-severity-dot" data-severity="' + escapeAttr(sevForStatus(it.status)) + '"></span>' +
'<span class="findings__item-id">C-' + String(idx + 1).padStart(2, '0') + '</span>' +
'<span class="findings__item-title">' + escapeHtml(it.requirement) + '</span>' +
'<span class="findings__item-meta">Bevis: ' + escapeHtml(it.evidence || '—') + ' · ' + escapeHtml(it.status || '—') + '</span>' +
'</li>';
}).join('');
const findingsHtml =
'<div class="findings">' +
'<div class="findings__list">' +
'<div class="findings__group">' +
'<div class="findings__group-header"><span>Sjekkliste</span><span>' + (data.checklist || []).length + '</span></div>' +
'<ul class="findings__items">' + items + '</ul>' +
'</div>' +
'</div>' +
'</div>';
slot.innerHTML = timelineHtml + findingsHtml;
}
function renderDpia(data, slot) {
slot.innerHTML = renderMatrixHtml(data, 5) + renderThreatsTable(data.threats);
}
// ---- Sub-batch B: Security (3) ----
function renderSecurity(data, slot) {
const matrixHtml = renderMatrixHtml(data, 6);
const radarHtml = renderRadarSvg(data.dimensions || []);
const findingsHtml = renderFindingsBlock(data.findings || [], 'Sikkerhetsfunn');
slot.innerHTML = matrixHtml + radarHtml + findingsHtml;
}
function renderRos(data, slot) {
const matrixHtml = renderMatrixHtml(data, 5);
const radarHtml = renderRadarSvg(data.radar_axes || []);
slot.innerHTML = matrixHtml + radarHtml + renderThreatsTable(data.threats);
}
function renderReview(data, slot) {
slot.innerHTML = renderFindingsBlock(data.findings || [], 'Funn');
}
// ---- Sub-batch C: Economy (2) ----
function renderCost(data, slot) {
const p10 = data.p10 ? data.p10.monthly : 0;
const p50 = data.p50 ? data.p50.monthly : 0;
const p90 = data.p90 ? data.p90.monthly : 0;
const max = Math.max(p10, p50, p90, 1);
const distRows = [
{ label: 'P10 (lavt)', value: p10 },
{ label: 'P50 (median)', value: p50 },
{ label: 'P90 (høyt)', value: p90 }
].map(function (r) {
const w = (r.value / max) * 100;
return '<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>'
: '';
slot.innerHTML = distHtml + breakdownHtml + tcoHtml;
}
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('');
slot.innerHTML = '<div class="capability-matrix" style="grid-template-columns: 220px repeat(' + licenses.length + ', 1fr);">' +
headHtml + rowsHtml + '</div>';
}
// ---- Sub-batch D: Documentation (6) ----
function renderMigrate(data, slot) {
const phases = data.phases || [];
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
const milestones = phases.map(function (p, i) {
const left = ((i + 1) / (phases.length + 1)) * 100;
return '<div class="aiact-timeline__milestone" data-state="upcoming" style="left: ' + left.toFixed(1) + '%">' +
'<div class="aiact-timeline__dot"></div>' +
'<div class="aiact-timeline__label">' +
'<span class="aiact-timeline__label-date">' + (p.duration_weeks ? p.duration_weeks + ' uker' : '') + '</span>' +
'<span class="aiact-timeline__label-name">' + escapeHtml(p.name) + '</span>' +
'</div>' +
'</div>';
}).join('');
const timelineHtml =
'<div class="aiact-timeline">' +
'<div class="aiact-timeline__track">' +
'<div class="aiact-timeline__progress" style="width: 0%"></div>' +
milestones +
'</div>' +
'</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>'
: '';
slot.innerHTML = timelineHtml + detailsHtml + risksHtml;
}
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>';
const sectionsHtml = (data.sections || []).map(function (s) {
return '<section><h2>' + escapeHtml(s.heading) + '</h2><div class="adr-body">' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</div></section>';
}).join('');
slot.innerHTML =
'<article class="report-doc">' +
'<h1>' + escapeHtml(data.title || 'ADR') + '</h1>' +
meta +
sectionsHtml +
'</article>';
}
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>';
const rationaleHtml = data.rationale
? '<section><h3>Rationale</h3><p>' + escapeHtml(data.rationale).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>'
: '';
slot.innerHTML = verdictHtml + rationaleHtml + metricsHtml + nextHtml;
}
function renderPoc(data, slot) {
const phases = data.phases || [];
if (!phases.length) { slot.innerHTML = renderEmptyState(); return; }
const stagesHtml = phases.map(function (p, i) {
const num = String(i + 1).padStart(2, '0');
const isCurrent = (i === 0);
return '<div class="pc-stage" data-current="' + (isCurrent ? 'true' : 'false') + '">' +
'<div class="pc-stage__num">' + num + '</div>' +
'<div class="pc-stage__name">' + escapeHtml(p.name) + '</div>' +
'<div class="pc-stage__state" data-state="' + (isCurrent ? 'running' : 'empty') + '">' + (p.duration_weeks || '?') + ' uker</div>' +
'</div>';
}).join('');
const cockpitHtml = '<div class="pipeline-cockpit">' + stagesHtml + '</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>'
: '';
slot.innerHTML = cockpitHtml + detailsHtml + risksHtml;
}
function renderUtredning(data, slot) {
const tocHtml = (data.sections || []).map(function (s, i) {
return '<li><a href="#utr-sec-' + i + '">' + escapeHtml(s.heading) + '</a></li>';
}).join('');
const sectionsHtml = (data.sections || []).map(function (s, i) {
return '<section id="utr-sec-' + i + '"><h2>' + escapeHtml(s.heading) + '</h2><div>' + escapeHtml(s.body).replace(/\n/g, '<br>') + '</div></section>';
}).join('');
slot.innerHTML =
'<div class="report-utredning" style="display: grid; grid-template-columns: 220px 1fr; gap: var(--space-6);">' +
'<aside class="utredning-toc"><h4>Innhold</h4><ol>' + tocHtml + '</ol></aside>' +
'<article class="report-doc">' +
'<h1>' + escapeHtml(data.title || 'Utredning') + '</h1>' +
sectionsHtml +
'</article>' +
'</div>';
}
function renderCompare(data, slot) {
const subjects = (data.subjects && data.subjects.length === 2) ? data.subjects : ['Subjekt 1', 'Subjekt 2'];
const firstWord = function (s) { return (s || '').toLowerCase().split(/\s+/)[0] || ''; };
const fw1 = firstWord(subjects[0]);
const fw2 = firstWord(subjects[1]);
let count1 = 0, count2 = 0, lik = 0;
(data.rows || []).forEach(function (r) {
const w = (r.winner || '').toLowerCase();
if (!w || /lik|begge|—|-/.test(w)) lik++;
else if (fw1 && w.indexOf(fw1) >= 0) count1++;
else if (fw2 && w.indexOf(fw2) >= 0) count2++;
else lik++;
});
const summaryHtml =
'<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('');
slot.innerHTML = '<div class="diff">' + summaryHtml + headerHtml + rowsHtml + '</div>';
}
// ---- 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);
slot.innerHTML = '';
if (result.ok) renderer(result.data, slot);
else renderError(result.errors, slot);
}
window.__handlePasteImport = handlePasteImport;
// ============================================================
// ONBOARDING SURFACE (Step 5)
// ============================================================
//
// 18 felles felter strukturert i 5 grupper per agents/onboarding-agent.md
// Phase 1-5. Sidebar = .form-progress med count utfylte felter per gruppe.
// Hver gruppe = .expansion (Tier 3 supplement). Validering bruker
// .error-summary (Tier 3) med klikkbare links som fokuserer feil-felt.
//
// Lagring: commitOnboarding() muterer state.shared.<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.
const ONBOARDING_SCHEMA = [
{
id: 'organization',
title: 'Virksomhetsprofil',
sub: 'Hvem er dere?',
fields: [
{ id: 'name', label: 'Virksomhetsnavn', type: 'text', required: true },
{ id: 'description', label: 'Beskrivelse', type: 'textarea' },
{ id: 'sector', label: 'Sektor', type: 'select', required: true,
options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
{ id: 'size', label: 'Antall ansatte', type: 'select',
options: ['<100', '100-500', '500-2000', '2000-10000', '>10000'] },
{ id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'multiSelect',
options: ['Personopplysningsloven/GDPR', 'Sikkerhetsloven', 'Arkivloven', 'Forvaltningsloven', 'Offentleglova', 'Helseregisterloven', 'Annet'] }
]
},
{
id: 'technology',
title: 'Teknologistack',
sub: 'Hva har dere allerede?',
fields: [
{ id: 'cloud_platform', label: 'Skyplattform', type: 'multiSelect',
options: ['Azure', 'M365', 'Power Platform', 'On-prem', 'Hybrid', 'Annet'] },
{ id: 'license_type', label: 'Lisenstype', type: 'select',
options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Annet'] },
{ id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'multiSelect',
options: ['Azure OpenAI', 'Copilot for M365', 'Copilot Studio', 'AI Builder', 'Azure AI Search', 'Azure AI Services', 'Ingen', 'Annet'] }
]
},
{
id: 'security',
title: 'Sikkerhet og compliance',
sub: 'Hvilke krav styrer dere etter?',
fields: [
{ id: 'data_classification', label: 'Dataklassifisering', type: 'multiSelect',
options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig', 'Hemmelig'] },
{ id: 'data_residency', label: 'Dataresidens-krav', type: 'select',
options: ['Norge', 'Norden', 'EU/EØS', 'Ingen spesifikke krav'] },
{ id: 'dpia_practice', label: 'DPIA-praksis', type: 'select',
options: ['Systematisk', 'Ad hoc', 'Ikke etablert', 'Usikker'] },
{ id: 'certifications', label: 'Sertifiseringer/rammeverk', type: 'textarea' }
]
},
{
id: 'architecture',
title: 'Arkitekturbeslutninger',
sub: 'Hvor vil dere?',
fields: [
{ id: 'preferred_platform', label: 'Foretrukket AI-plattform', type: 'select',
options: ['Azure AI Foundry', 'Copilot Studio', 'Power Platform/AI Builder', 'Semantic Kernel', 'Ikke bestemt'] },
{ id: 'integration_needs', label: 'Integrasjonsbehov', type: 'multiSelect',
options: ['M365', 'SharePoint', 'Dynamics 365', 'SAP', 'Fagsystemer', 'REST API-er', 'Annet'] },
{ id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'select',
options: ['<500k', '500k-2M', '2M-10M', '>10M', 'Ikke definert'] }
]
},
{
id: 'business',
title: 'Forretningsreferanser',
sub: 'Hvordan styrer dere?',
fields: [
{ id: 'governance_model', label: 'Styringsmodell for AI', type: 'select',
options: ['Sentralisert', 'Desentralisert', 'Hybrid', 'Ikke etablert'] },
{ id: 'doc_format_preferences', label: 'Dokumentformat', type: 'multiSelect',
options: ['Markdown', 'Word', 'PDF', 'Confluence', 'SharePoint Wiki', 'Annet'] },
{ id: 'reference_architecture', label: 'Referansearkitektur', type: 'textarea' }
]
}
];
function fieldFilled(value, type) {
if (value == null) return false;
if (type === 'multiSelect') return Array.isArray(value) && value.length > 0;
if (type === 'boolean') return value === true;
return String(value).trim() !== '';
}
function getOnboardingValue(groupId, fieldId) {
const grp = store.state.shared && store.state.shared[groupId];
if (!grp) return undefined;
return grp[fieldId];
}
function groupProgress(group) {
let filled = 0;
for (let i = 0; i < group.fields.length; i++) {
const f = group.fields[i];
if (fieldFilled(getOnboardingValue(group.id, f.id), f.type)) filled++;
}
return { filled: filled, total: group.fields.length };
}
function renderOnboardingField(field, fieldId, groupId, value) {
const path = groupId + '.' + field.id;
const dataAttrs = 'data-onboarding-field="' + escapeAttr(path) + '"';
const requiredMark = field.required ? '<span class="required-mark" aria-hidden="true">*</span>' : '';
const labelHtml = '<label for="' + fieldId + '" class="field-label">' + escapeHtml(field.label) + requiredMark + '</label>';
let inputHtml = '';
if (field.type === 'text') {
inputHtml = '<input type="text" id="' + fieldId + '" ' + dataAttrs + ' value="' + escapeAttr(value || '') + '" class="input">';
} else if (field.type === 'textarea') {
inputHtml = '<textarea id="' + fieldId + '" ' + dataAttrs + ' 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 actionBar = (
'<div class="onboarding-actions">' +
'<button type="button" class="btn btn--primary" data-action="onboarding-save">Lagre og fortsett</button>' +
skipBackBtn +
'<span class="onboarding-help">Du kan endre alt senere via Re-onboard.</span>' +
'</div>'
);
root.innerHTML = (
'<div class="app-shell">' +
'<div class="onboarding-layout">' +
sidebar +
'<div class="onboarding-main">' +
'<header class="onboarding-header">' +
'<h1>Velkommen til ms-ai-architect</h1>' +
'<p>Fyll inn 18 felles felter — de gjenbrukes på tvers av alle commands og forhåndsutfyller skjemaer per prosjekt.</p>' +
'</header>' +
errorSummary +
'<div class="onboarding-groups">' + groupsHtml + '</div>' +
actionBar +
'</div>' +
'</div>' +
'</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');
};
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['parse'] = function (ev, el) {
const commandId = el.dataset.command;
if (!commandId) return;
// Finn nærmeste paste-import textarea (project-overflate eller modal — Step 9
// bruker ikke parse-knapp, men vi holder oss generisk via closest()).
const scope = el.closest('[data-modal-root], [data-surface]') || document;
const textarea = scope.querySelector('[data-paste-import="' + commandId + '"]');
if (!textarea) return;
const markdown = textarea.value || '';
handlePasteImport(commandId, markdown);
};
// ---- Step 8: copy-command + preview-command ----
ACTIONS['copy-command'] = function (ev, el) {
const commandId = el.dataset.command;
const formEl = el.closest('[data-command-form]');
if (!commandId || !formEl) return;
const data = readCommandFormValues(formEl);
const cmdString = buildCommand(commandId, data);
// Vis preview alltid — clipboard kan feile på file://-protokoll i noen browsers.
showCommandPreview(formEl, cmdString);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(cmdString).then(function () {
flashCopyConfirm(formEl, 'Kopiert til utklippstavle.');
}).catch(function (err) {
console.warn('[playground v3] clipboard write feilet:', err);
flashCopyConfirm(formEl, 'Kunne ikke kopiere — bruk forhåndsvisningen under.');
});
} else {
flashCopyConfirm(formEl, 'Clipboard utilgjengelig — bruk forhåndsvisningen under.');
}
};
ACTIONS['preview-command'] = function (ev, el) {
const commandId = el.dataset.command;
const formEl = el.closest('[data-command-form]');
if (!commandId || !formEl) return;
const data = readCommandFormValues(formEl);
showCommandPreview(formEl, buildCommand(commandId, data));
};
// ---- Step 9: catalog actions ----
ACTIONS['open-catalog-form'] = function (ev, el) {
const commandId = el.dataset.command;
if (!commandId) return;
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
if (!cmd) return;
mountModal(renderCatalogFormModalHtml(cmd));
};
ACTIONS['catalog-toggle-group'] = function (ev, el) {
const exp = el.closest('.expansion');
if (!exp) return;
const open = exp.getAttribute('aria-expanded') === 'true';
exp.setAttribute('aria-expanded', open ? 'false' : 'true');
};
// Søk-input: input-event oppdaterer query og re-rendrer kun groups-containeren
// (bevarer fokus/cursor i selve søke-feltet — full re-render ville flyttet caret).
document.addEventListener('input', function (ev) {
if (!ev.target.matches || !ev.target.matches('[data-catalog-search]')) return;
catalogSearchQuery = ev.target.value || '';
refreshCatalogResults();
});
// Eksponer for Verify-asserts og Step 8/9/12.
window.__SCENARIOS = SCENARIOS;
window.__createProject = createProject;
window.__deleteProject = deleteProject;
window.__findProject = findProject;
window.__mountModal = mountModal;
window.__unmountModal = unmountModal;
window.__buildCommand = buildCommand;
window.__renderCommandForm = renderCommandForm;
window.__readCommandFormValues = readCommandFormValues;
window.__resolveSharedPath = resolveSharedPath;
window.__renderCatalogSurface = renderCatalogSurface;
window.__refreshCatalogResults = refreshCatalogResults;
ACTIONS['export-state'] = function () {
try { exportState(); }
catch (err) { console.error('[playground v3] export feilet:', err); alert('Eksport feilet: ' + err.message); }
};
ACTIONS['import-state'] = function (ev, el) {
const topbar = el.closest('.topbar');
if (!topbar) return;
const input = topbar.querySelector('[data-import-input]');
if (!input) return;
input.value = ''; // tillat samme fil to ganger
input.click();
};
// File-input change handler (én gang for hele dokumentet — input genereres
// fortløpende via renderTopbar, men endringen bobler).
document.addEventListener('change', function (ev) {
if (!ev.target.matches || !ev.target.matches('[data-import-input]')) return;
const file = ev.target.files && ev.target.files[0];
if (!file) return;
importState(file)
.then(function () {
scheduleRender();
alert('Import fullført. Nåværende state er erstattet av filens innhold.');
})
.catch(function (err) {
console.error('[playground v3] import feilet:', err);
alert('Import feilet: ' + err.message);
});
});
// Eksponer for Verify-asserts og Steps 6-9.
window.__navigate = navigate;
window.__scheduleRender = scheduleRender;
window.__ONBOARDING_SCHEMA = ONBOARDING_SCHEMA;
window.__readOnboardingValues = readOnboardingValues;
window.__validateOnboarding = validateOnboarding;
window.__commitOnboarding = commitOnboarding;
// Auto-bootstrap. Kjør så snart DOM er parsed; vi er på slutten av <body>
// så DOM er allerede klar.
bootstrap().catch(function (err) {
console.error('[playground v3] bootstrap failed:', err);
});
})();
</script>
</body>
</html>