Step 8 of v3 plan. renderCommandForm(commandId, opts) reads
CATALOG[id].input_fields and emits a form with all 6 supported field types
(text/textarea/select/multiSelect/boolean/number). Shared fields
auto-prefill from state.shared via field.shared_path dot-lookup; local
fields prefill from project.reports[id].input when opts.projectId is set.
window.__buildCommand(commandId, formData) builds /architect:<id>
key="value" key="value" ... — shared fields merged first (CATALOG order),
formData overrides and may include keys outside the catalog (passthrough).
Empty/null/empty-array values omitted. Multi-values comma-joined inside
quotes; quotes/backslashes escaped.
Copy-button writes via navigator.clipboard.writeText with graceful
fallback to inline preview when clipboard is blocked (file:// in some
browsers). Preview-button shows the same string without copying.
Replaces the form-zone-placeholder in renderCommandSubCard. All 24
command-cards in project-detail now render real forms (verified:
data-command-card === 24, data-command-form === 24, copy-command
buttons === 24, field-from-tag === 39, paste-import === 17,
report-slot === 17, buildCommand('classify',{riskLevel:'høy'}) →
'/architect:classify organisation_name="Vegvesen" sector="Statlig"
riskLevel="høy"').
2519 lines
121 KiB
HTML
2519 lines
121 KiB
HTML
<!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>
|
||
|
||
<!-- 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; }
|
||
.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 S0–S9 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
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') renderCatalogStub();
|
||
}
|
||
|
||
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>'
|
||
: '';
|
||
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>' +
|
||
'</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>'
|
||
);
|
||
}
|
||
|
||
function renderCatalogStub() {
|
||
const root = getSurfaceEl('catalog');
|
||
if (!root) return;
|
||
root.innerHTML = renderTopbar('Katalog') + '<div class="app-shell"><p>Command-katalog fylles i Step 9.</p></div>';
|
||
}
|
||
|
||
// ---- Paste-import stub (Step 12 erstatter med faktisk routing) ----
|
||
|
||
function handlePasteImport(commandId, markdown) {
|
||
// Stub: logger til konsoll for å verifisere DOM-kontrakten. Step 12
|
||
// henter PARSERS[CATALOG[id].report_archetype] + RENDERERS[id], parser
|
||
// markdown og injiserer i [data-report-slot="<id>"].
|
||
console.log('parse-pending:', commandId, (markdown || '').slice(0, 80));
|
||
const slot = document.querySelector('[data-report-slot="' + commandId + '"]');
|
||
if (slot) {
|
||
slot.innerHTML = '<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">Markdown mottatt (' + (markdown || '').length + ' tegn). Parser+renderer kommer i Step 12.</p></div></div>';
|
||
}
|
||
}
|
||
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'); };
|
||
|
||
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));
|
||
};
|
||
|
||
// 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;
|
||
|
||
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>
|