ktg-plugin-marketplace/plugins/llm-security/playground/llm-security-playground.html
Kjell Tore Guttormsen fba0adf17c feat(llm-security): playground Fase 1 — single-file SPA skjelett [skip-docs]
Mirror av ms-ai-architect playground-arkitektur, tilpasset llm-security:

- 4 overflater (onboarding/home/catalog/project) med surface-router
- IndexedDB persistens (llm-security-playground-v1) + localStorage fallback
- Theme-bootstrap med FOUC-prevention og localStorage-persist
- 20 kommandoer i CATALOG (5 kategorier: discover/posture/findings-ops/
  hardening/adversarial/mcp-ops) med full input_fields + report_archetype
- 5-gruppers onboarding (organisasjon/scope/profil/plattform/compliance)
  med form-progress sidebar
- Home: 3 tracks + fleet-grid prosjektliste + tom-state med demo-data
- Katalog: ekspanderbare grupper med live-søk og forhåndsvisning
- Prosjekt-stub: 4 screen-tabs + 6 kategori-tabs + per-kommando
  skjema/paste-import/rapport-soner
- Demo-state: Direktoratet for digital tjenesteutvikling med 2 prosjekter
- Eksport/import (JSON envelope), action-handlers (35), modal-portal

PARSERS + RENDERERS er tomme routing-objekter — fylles i Fase 2 (10 høy-prio
kommandoer) og Fase 3 (resterende 10). Paste-import viser «parser ikke
implementert»-guide-panel for kommandoer uten parser, og lagrer rå markdown
i state for fremtidig parsing.

Vendor: 27 filer synket fra shared/playground-design-system/
(MANIFEST.json sjekksum-låst, source_commit 487f7ae).

Verifisert: node --check OK (2737 linjer, 113733 char inline JS),
HTML-tag-balanse OK. Manuell smoke-test gjenstår.

Docs (plugin README, CLAUDE.md, rot-README) bumpes ved Fase 3-fullføring
sammen med plugin.json v7.5.0. Derfor [skip-docs] her.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 18:47:45 +02:00

2737 lines
137 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="nb" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llm-security — Playground v1</title>
<!-- playground-design-system v0.1 (vendored) -->
<!-- Theme bootstrap. Må kjøre før stylesheets parses for å unngå
flash-of-wrong-theme (FOUC). Prioritet:
1) lagret valg (localStorage 'llm-security-theme')
2) OS-preferanse via matchMedia('(prefers-color-scheme: dark)')
3) HTML-attributtets default ('dark')
Setter både data-theme + colorScheme for native form-controls/scrollbars.
Wrappes i try/catch — file:// + privatmodus kan blokkere localStorage. -->
<script>
(function () {
var theme = null;
try {
var saved = localStorage.getItem('llm-security-theme');
if (saved === 'light' || saved === 'dark') theme = saved;
} catch (e) { /* localStorage utilgjengelig */ }
if (!theme && window.matchMedia) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
})();
</script>
<!-- Vendored design-system. Kilden er shared/playground-design-system/ — synces via
scripts/sync-design-system.mjs ved marketplace-rot. Aldri rediger filer under
playground/vendor/ direkte; endringer går i shared/ + re-sync. -->
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
<link rel="stylesheet" href="vendor/playground-design-system/print.css" media="print">
<style>
/* App-shell layout. Vendored design-system levner komponent-CSS;
her bor kun side-spesifikk layout-grid (sidebar+main, modals, sub-cards). */
main#app { min-height: 100vh; padding: 0; }
[hidden] { display: none !important; }
/* 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; }
.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-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-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-scope-security, 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); }
.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; }
/* Project header chips */
.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); }
.scenario-tag { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--color-scope-security-soft, var(--color-primary-100)); color: var(--color-scope-security-on, var(--color-primary-700)); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); }
/* Tracks (DS) som hero på home — fallback hvis ikke i tier3 wave 2 */
.tracks { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-5); }
@media (max-width: 880px) { .tracks { grid-template-columns: 1fr; } }
.tracks__card { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-surface); cursor: pointer; text-align: left; font-family: inherit; color: var(--color-text-primary); transition: border-color 120ms, transform 120ms; }
.tracks__card:hover { border-color: var(--color-scope-security, var(--color-primary-500)); transform: translateY(-1px); }
.tracks__card-icon { font-size: var(--font-size-2xl); width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; }
.tracks__card-title { font-size: var(--font-size-lg); margin: 0; font-weight: var(--font-weight-semibold); }
.tracks__card-desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 0; }
.tracks__card-meta { display: flex; justify-content: space-between; font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-top: var(--space-2); }
.tracks__card-cta { color: var(--color-scope-security, var(--color-primary-500)); font-weight: var(--font-weight-semibold); }
/* Fleet-grid (project list) */
.fleet-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--space-3); }
.fleet-tile { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-surface); cursor: pointer; text-align: left; font-family: inherit; color: var(--color-text-primary); transition: border-color 120ms; }
.fleet-tile:hover { border-color: var(--color-scope-security, var(--color-primary-500)); }
.fleet-tile__row { display: flex; justify-content: space-between; align-items: center; gap: var(--space-2); }
.fleet-tile__name { font-weight: var(--font-weight-semibold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fleet-tile__chip { font-size: var(--font-size-xs); padding: 2px 8px; border-radius: var(--radius-pill); background: var(--color-bg-soft); color: var(--color-text-secondary); white-space: nowrap; }
.fleet-tile__meter { width: 100%; height: 6px; background: var(--color-bg-soft); border-radius: var(--radius-pill); overflow: hidden; }
.fleet-tile__meter-fill { display: block; height: 100%; border-radius: inherit; transition: width 200ms; }
.fleet-tile__meter-fill[data-band="1"] { background: var(--color-severity-low); }
.fleet-tile__meter-fill[data-band="2"] { background: var(--color-severity-medium); }
.fleet-tile__meter-fill[data-band="3"] { background: var(--color-severity-high); }
.fleet-tile__meter-fill[data-band="4"] { background: var(--color-severity-critical); }
.fleet-tile__meta { display: flex; justify-content: space-between; font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
/* Command form patterns */
.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; }
.command-form__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); margin-left: auto; }
.command-form__copy-confirm { font-size: var(--font-size-xs); color: var(--color-state-success); font-weight: var(--font-weight-medium); }
.form-preview { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); }
.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-sm); color: var(--color-text-primary); margin: 0; white-space: pre-wrap; word-break: break-all; }
/* Field row */
.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); display: flex; align-items: center; gap: 6px; }
.field-from-tag { font-size: 10px; font-weight: var(--font-weight-medium); padding: 1px 6px; border-radius: var(--radius-pill); background: var(--color-scope-security-soft, var(--color-primary-100)); color: var(--color-scope-security-on, var(--color-primary-700)); text-transform: uppercase; letter-spacing: 0.06em; cursor: help; }
.field-help { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.input, .textarea, .select { font-family: inherit; font-size: var(--font-size-sm); padding: 8px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface); color: var(--color-text-primary); }
.input:focus, .textarea:focus, .select:focus { outline: 2px solid var(--color-scope-security, var(--color-primary-500)); outline-offset: 1px; border-color: transparent; }
.textarea { resize: vertical; font-family: inherit; }
.multi-select { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); background: var(--color-surface); }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-sm); cursor: pointer; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Card patterns */
.card { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-surface); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
.card__title { font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); margin: 0 0 4px; }
.card__desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 0; }
.card__hint { display: inline-block; padding: 2px 6px; border-radius: var(--radius-sm); background: var(--color-bg-soft); font-family: var(--font-family-mono); font-size: 11px; color: var(--color-text-tertiary); margin-top: 6px; }
.card__pill { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-pill); background: var(--color-bg-soft); color: var(--color-text-secondary); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); white-space: nowrap; }
.card__actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
/* Catalog */
.catalog-search { width: 100%; max-width: 480px; margin-bottom: var(--space-5); }
.catalog-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3); margin-top: var(--space-3); }
.catalog-tool-notice { padding: 8px 12px; background: var(--color-bg-soft); border-left: 3px solid var(--color-state-info, var(--color-primary-300)); font-size: var(--font-size-xs); color: var(--color-text-secondary); border-radius: var(--radius-sm); }
.expansion { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow: hidden; background: var(--color-surface); margin-bottom: var(--space-3); }
.expansion__head { display: flex; align-items: center; gap: var(--space-3); width: 100%; padding: var(--space-3) var(--space-4); background: transparent; border: 0; cursor: pointer; font-family: inherit; text-align: left; color: var(--color-text-primary); }
.expansion__head:hover { background: var(--color-bg-soft); }
.expansion__title { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.expansion__title-main { font-weight: var(--font-weight-semibold); font-size: var(--font-size-md); }
.expansion__title-sub { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.expansion__chev { font-size: var(--font-size-md); color: var(--color-text-tertiary); transition: transform 120ms; }
.expansion[aria-expanded="true"] .expansion__chev { transform: rotate(180deg); }
.expansion__body { padding: 0 var(--space-4) var(--space-4); border-top: 1px solid var(--color-border-subtle); }
.expansion[aria-expanded="false"] .expansion__body { display: none; }
/* 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-5); }
.modal { background: var(--color-surface); border-radius: var(--radius-md); max-width: 720px; width: 100%; max-height: 90vh; overflow: auto; padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
.modal__head { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
.modal__title { font-size: var(--font-size-xl); font-weight: var(--font-weight-semibold); margin: 0; }
.modal__close { background: transparent; border: 0; cursor: pointer; font-size: 24px; line-height: 1; padding: 4px 8px; color: var(--color-text-tertiary); }
.modal__close:hover { color: var(--color-text-primary); }
/* Page shell */
.page__header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-5) 0 var(--space-4); border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); }
.page__header-main { display: flex; flex-direction: column; gap: var(--space-2); flex: 1; }
.page__eyebrow { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.1em; color: var(--color-scope-security, var(--color-primary-500)); font-weight: var(--font-weight-semibold); }
.page__title { font-size: var(--font-size-2xl); margin: 0; }
.page__lede { font-size: var(--font-size-md); color: var(--color-text-secondary); margin: 0; max-width: 65ch; }
.page__header-aside { flex-shrink: 0; }
.key-stats { display: flex; gap: var(--space-3); flex-wrap: wrap; padding: var(--space-3) 0; border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); }
.key-stat { display: flex; flex-direction: column; gap: 2px; min-width: 120px; }
.key-stat__label { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); }
.key-stat__value { font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); font-variant-numeric: tabular-nums; }
.key-stat__hint { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
.key-stat--crit .key-stat__value { color: var(--color-severity-critical); }
.key-stat--high .key-stat__value { color: var(--color-severity-high); }
.key-stat--med .key-stat__value { color: var(--color-severity-medium); }
.key-stat--low .key-stat__value { color: var(--color-severity-low); }
/* Verdict pill */
.verdict-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: var(--radius-pill); font-size: var(--font-size-xs); font-weight: var(--font-weight-bold); letter-spacing: 0.06em; text-transform: uppercase; }
.verdict-pill[data-verdict="go"], .verdict-pill[data-verdict="approved"], .verdict-pill[data-verdict="allow"] { background: var(--color-state-success-soft, var(--color-severity-low-soft)); color: var(--color-state-success, var(--color-severity-low-on)); }
.verdict-pill[data-verdict="warning"], .verdict-pill[data-verdict="go-with-conditions"] { background: var(--color-severity-medium-soft); color: var(--color-severity-medium-on); }
.verdict-pill[data-verdict="block"], .verdict-pill[data-verdict="failed"] { background: var(--color-severity-critical-soft); color: var(--color-severity-critical-on); }
.verdict-pill[data-verdict="n-a"] { background: var(--color-bg-soft); color: var(--color-text-tertiary); }
/* Stack utilities */
.stack-sm > * + * { margin-top: var(--space-2); }
.stack-md > * + * { margin-top: var(--space-3); }
.stack-lg > * + * { margin-top: var(--space-5); }
/* App-shell */
.app-shell { max-width: 1200px; margin: 0 auto; padding: 0 var(--space-5) var(--space-12); }
.app-shell--wide { max-width: 1440px; }
/* Tab list (project screen tabs) */
.tab-list { display: flex; gap: 2px; margin-bottom: var(--space-4); border-bottom: 1px solid var(--color-border-subtle); }
.tab { background: transparent; border: 0; padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: var(--font-size-sm); color: var(--color-text-secondary); border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tab:hover { color: var(--color-text-primary); }
.tab[aria-current="true"] { color: var(--color-text-primary); border-bottom-color: var(--color-scope-security, var(--color-primary-500)); font-weight: var(--font-weight-semibold); }
/* Form-progress sidebar (onboarding) */
.form-progress { position: sticky; top: var(--space-5); padding: var(--space-4); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: var(--space-2); }
.form-progress__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); }
.form-progress__step { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); font-size: var(--font-size-sm); cursor: pointer; background: transparent; border: 0; font-family: inherit; text-align: left; color: var(--color-text-secondary); }
.form-progress__step:hover { background: var(--color-bg-soft); }
.form-progress__step[aria-current="step"] { background: var(--color-bg-soft); color: var(--color-text-primary); font-weight: var(--font-weight-medium); }
.form-progress__step-marker { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--color-border-default); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: var(--font-weight-bold); color: var(--color-text-tertiary); flex-shrink: 0; }
.form-progress__step--done .form-progress__step-marker { background: var(--color-scope-security, var(--color-primary-500)); border-color: var(--color-scope-security, var(--color-primary-500)); color: var(--color-text-on-primary, white); }
.form-progress__step[aria-current="step"] .form-progress__step-marker { border-color: var(--color-scope-security, var(--color-primary-500)); color: var(--color-scope-security, var(--color-primary-500)); }
/* Eyebrow utility */
.eyebrow { font-size: var(--font-size-xs); text-transform: uppercase; letter-spacing: 0.1em; color: var(--color-scope-security, var(--color-primary-500)); font-weight: var(--font-weight-semibold); display: block; margin-bottom: var(--space-2); }
/* Required mark */
.required-mark { color: var(--color-severity-critical); margin-left: 2px; }
</style>
</head>
<body>
<!-- Walking-skeleton: 4 placeholder-overflater. Drevet av state.activeSurface.
Bare én er aktiv om gangen. -->
<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>
<!-- Modal-portal — vises kun ved aktiv modal -->
<div id="modal-root"></div>
<!-- Inlined demo-state for "Last inn demo-data"-knapp.
Mirror av shared/playground-examples/security-direktorat.html-scenario.
I Fase 2/3 utvides denne med fulle parsed-rapporter; her i Fase 1 er
reports{} tom på begge prosjekter. -->
<script type="application/json" id="demo-state-v1">
{
"schemaVersion": 1,
"dataVersion": 2,
"shared": {
"organization": {
"name": "Direktoratet for digital tjenesteutvikling",
"sector": "Statlig",
"size": "1 200",
"description": "Direktorat med ansvar for digitaliseringspolitikk og fellesløsninger for offentlig sektor. Har 38 produksjonssatte AI-tjenester og fungerer som referansevirksomhet for sikkerhets-praksis."
},
"scope": {
"typical_paths": "~/repos/dft-platform, ~/repos/dft-shared-services, ~/.claude/plugins/marketplaces/dft",
"exclude_patterns": "node_modules, dist, build, *.test.ts, fixtures/, vendor/",
"github_orgs": "dft-norge, dft-shared, dft-experiments",
"mcp_servers": ["filesystem", "github", "memory", "fetch"],
"ide_in_use": ["VS Code", "IntelliJ IDEA", "Cursor"]
},
"profile": {
"severity_threshold": "high",
"strict_mode": true,
"ci_failon": "high",
"suppress_categories": ["docs-only-changes"]
},
"platform": {
"ide_list": ["VS Code", "IntelliJ IDEA", "Cursor"],
"mcp_count": 4,
"ci_system": "GitHub Actions",
"runtime_envs": ["macOS", "Linux", "Docker"]
},
"compliance": {
"frameworks": ["OWASP LLM Top 10", "OWASP Agentic (ASI)", "OWASP Skills (AST)", "OWASP MCP", "EU AI Act", "NIST AI RMF"],
"datatilsynet_consulted": true,
"gdpr_role": "controller",
"ai_act_role": "deployer"
}
},
"projects": [
{
"id": "dft-marketplace-scan",
"name": "DFT marketplace baseline-skann",
"description": "Komplett scan av eget plugin-marketplace (8 plugins, 47 commands, 23 hooks, 12 MCP-tilkoblinger). Skal etablere Grade A-baseline før neste release.",
"target_type": "codebase",
"target_path": "~/repos/dft-marketplace",
"scenarios": ["pre-deploy", "compliance-audit"],
"createdAt": "2026-05-04T08:00:00.000Z",
"reports": {}
},
{
"id": "dft-mcp-airbnb-audit",
"name": "MCP-server-audit: airbnb-mcp",
"description": "Tredjeparts MCP-server vurdert for innføring. Trust-vurdering, supply-chain-sjekk og live tool-deskripsjons-skann.",
"target_type": "mcp-server",
"target_path": "https://github.com/airbnb/mcp-server",
"scenarios": ["mcp-supply-chain", "plugin-trust"],
"createdAt": "2026-05-05T10:30:00.000Z",
"reports": {}
}
],
"activeProjectId": "dft-marketplace-scan",
"activeSurface": "project",
"preferences": {
"theme": "dark"
}
}
</script>
<!--
Klassisk script (ikke type="module") av to grunner:
1. External <script type="module" src="..."> feiler på file:// i Chrome+Firefox.
2. Single-file deployment per brief Constraints — ingen build-step.
Fase 1 leverer skjelett: state, persistens, surface-router, onboarding/home/catalog/project-stub.
Fase 2 utvider PARSERS + RENDERERS for 10 høy-prio kommandoer.
Fase 3 utvider med resterende 10 + screenshots + 3-doc-update.
-->
<script>
(function () {
'use strict';
// ============================================================
// CONSTANTS
// ============================================================
const STATE_KEY = 'llm-security-state-v1';
const SCHEMA_VERSION = 1;
const APP_ID = 'llm-security-playground';
const PLUGIN_VERSION = '7.5.0-alpha';
window.__STATE_KEY = STATE_KEY;
window.__SCHEMA_VERSION = SCHEMA_VERSION;
window.__APP_ID = APP_ID;
// ============================================================
// STATE MODULE — Proxy + EventTarget med microtask-batch
// ============================================================
class StateBus extends EventTarget {}
const sharedBus = new StateBus();
const INITIAL_STATE = {
schemaVersion: SCHEMA_VERSION,
dataVersion: 2,
shared: {
organization: {},
scope: {},
profile: {},
platform: {},
compliance: {}
},
projects: [],
activeProjectId: null,
activeSurface: 'home',
preferences: { theme: 'dark' }
};
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 } }));
});
};
}
function deepProxy(target, dispatch, path) {
path = path || '';
const cache = new WeakMap();
function makeHandler(p) {
return {
get: function (o, k) {
const v = o[k];
if (v !== null && typeof v === 'object' && !(v instanceof Date)) {
if (cache.has(v)) return cache.get(v);
const childPath = p ? p + '.' + String(k) : String(k);
const wrapped = new Proxy(v, makeHandler(childPath));
cache.set(v, wrapped);
return wrapped;
}
return v;
},
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, makeHandler(path));
}
function createStore(initial, bus) {
const dispatch = makeBatchedDispatcher(bus);
const proxied = deepProxy(initial, dispatch, '');
return {
state: proxied,
raw: initial,
subscribe: function (handler) { bus.addEventListener('change', handler); },
unsubscribe: function (handler) { bus.removeEventListener('change', handler); }
};
}
function makeThrottledWriter(persist) {
let timer = null;
return function schedule() {
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
timer = null;
persist().catch(function (err) {
console.error('[llm-security playground] persist failed:', err);
});
}, 300);
};
}
// ============================================================
// PERSISTENCE — IDB primær, localStorage fallback
// ============================================================
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;
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');
}
};
req.onsuccess = function () {
const db = req.result;
db.onversionchange = function () {
db.close();
console.warn('[llm-security playground] IDB versionchange — closed for upgrade');
};
resolve(db);
};
req.onerror = function () { reject(req.error); };
req.onblocked = function () {
console.warn('[llm-security playground] IDB open blocked');
};
});
}
async function makePersistence() {
const DB_NAME = 'llm-security-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,
dataVersion: (metaReq.result && metaReq.result.dataVersion) || 2,
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');
projectStore.clear();
for (let i = 0; i < state.projects.length; i++) {
projectStore.put(state.projects[i]);
}
tx.objectStore('meta').put({
schemaVersion: state.schemaVersion,
dataVersion: state.dataVersion,
activeProjectId: state.activeProjectId,
activeSurface: state.activeSurface,
preferences: state.preferences
}, 'meta');
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
}
};
} catch (err) {
console.warn('[llm-security playground] 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('[llm-security playground] 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);
if (payload.length > 4.5 * 1024 * 1024) {
console.warn('[llm-security playground] State nærmer seg localStorage 5 MiB cap.');
}
localStorage.setItem(STATE_KEY, payload);
return Promise.resolve();
} catch (err) {
return Promise.reject(err);
}
}
};
}
// ============================================================
// BOOTSTRAP
// ============================================================
let store = null;
let persistence = null;
let scheduleWrite = null;
async function bootstrap() {
persistence = await makePersistence();
const loaded = await persistence.load();
if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION;
if (!loaded.dataVersion) loaded.dataVersion = 2;
try { migrateDataVersion(loaded, defaultArchetypeFor); }
catch (e) { console.warn('[llm-security playground] migrateDataVersion failed:', e); }
store = createStore(loaded, sharedBus);
scheduleWrite = makeThrottledWriter(function () {
return persistence.save(store.raw);
});
store.subscribe(function () { scheduleWrite(); });
window.__store = store;
window.__persistence = persistence;
// Initial-surface heuristikk
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
// ============================================================
function buildEnvelope() {
const snapshot = store ? JSON.parse(JSON.stringify(store.raw)) : JSON.parse(JSON.stringify(INITIAL_STATE));
return {
appId: APP_ID,
appVersion: PLUGIN_VERSION,
schemaVersion: snapshot.schemaVersion || SCHEMA_VERSION,
dataVersion: snapshot.dataVersion || 2,
exportedAt: new Date().toISOString(),
shared: snapshot.shared,
projects: snapshot.projects,
activeProjectId: snapshot.activeProjectId,
activeSurface: snapshot.activeSurface,
preferences: snapshot.preferences
};
}
function exportState() {
const env = buildEnvelope();
const payload = JSON.stringify(env, null, 2);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'llm-security-state-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
}
async function importState(file) {
const text = await file.text();
const env = JSON.parse(text);
if (env.appId !== APP_ID) {
throw new Error('Filen er ikke en llm-security-state-eksport (appId mismatch).');
}
const migrated = {
schemaVersion: env.schemaVersion || SCHEMA_VERSION,
dataVersion: env.dataVersion || 2,
shared: env.shared || INITIAL_STATE.shared,
projects: env.projects || [],
activeProjectId: env.activeProjectId || null,
activeSurface: env.activeSurface || 'home',
preferences: env.preferences || INITIAL_STATE.preferences
};
try { migrateDataVersion(migrated, defaultArchetypeFor); }
catch (e) { console.warn('[llm-security playground] migrateDataVersion (import) failed:', e); }
// Erstatt hele state-tre. Trigger persist via subscribe.
Object.keys(store.raw).forEach(function (k) { delete store.raw[k]; });
Object.keys(migrated).forEach(function (k) { store.raw[k] = migrated[k]; });
// Rebuild store på import (proxy-cache er skjelett-bundet til gammel raw)
store = createStore(store.raw, sharedBus);
window.__store = store;
scheduleRender();
}
function loadDemoState() {
const el = document.getElementById('demo-state-v1');
if (!el) return;
const env = JSON.parse(el.textContent);
Object.keys(store.raw).forEach(function (k) { delete store.raw[k]; });
Object.keys(env).forEach(function (k) { store.raw[k] = env[k]; });
store = createStore(store.raw, sharedBus);
window.__store = store;
scheduleRender();
}
// ============================================================
// UTILITIES
// ============================================================
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(str) { return escapeHtml(str); }
function uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'p-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
}
function findProject(id) {
const list = (store && store.state && store.state.projects) || [];
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
// ============================================================
// SHARED FIELD-SHORTHANDS + KATALOG
// ============================================================
const FIELD_TYPES = {
TEXT: 'text',
TEXTAREA: 'textarea',
SELECT: 'select',
MULTI_SELECT: 'multiSelect',
BOOLEAN: 'boolean',
NUMBER: 'number'
};
const SEVERITY_LEVELS = ['low', 'medium', 'high', 'critical'];
const FRAMEWORK_OPTIONS = [
'OWASP LLM Top 10', 'OWASP Agentic (ASI)', 'OWASP Skills (AST)', 'OWASP MCP',
'EU AI Act', 'NIST AI RMF', 'ISO 42001', 'Datatilsynet'
];
const IDE_OPTIONS = [
'VS Code', 'Cursor', 'Windsurf', 'VSCodium', 'IntelliJ IDEA', 'PyCharm',
'GoLand', 'WebStorm', 'RubyMine', 'PhpStorm', 'CLion', 'Android Studio', 'Annet'
];
const RUNTIME_OPTIONS = ['macOS', 'Linux', 'Windows', 'Docker', 'WSL'];
const CI_OPTIONS = ['GitHub Actions', 'GitLab CI', 'Azure Pipelines', 'Jenkins', 'CircleCI', 'Forgejo Actions', 'Ingen', 'Annet'];
const SECTOR_OPTIONS = ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Privat', 'Frivillig', 'Annet'];
const SUPPRESS_CATEGORIES = ['docs-only-changes', 'test-fixtures', 'examples', 'archived-rules', 'experimental-features'];
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: SECTOR_OPTIONS },
severity_threshold: { id: 'severity_threshold', label: 'Severity-terskel', type: 'select', from: 'shared', shared_path: 'profile.severity_threshold', options: SEVERITY_LEVELS },
strict_mode: { id: 'strict_mode', label: 'Strict mode', type: 'boolean', from: 'shared', shared_path: 'profile.strict_mode' },
ci_failon: { id: 'ci_failon', label: 'CI fail-on severity', type: 'select', from: 'shared', shared_path: 'profile.ci_failon', options: SEVERITY_LEVELS },
frameworks: { id: 'frameworks', label: 'Compliance-rammeverk', type: 'multiSelect', from: 'shared', shared_path: 'compliance.frameworks', options: FRAMEWORK_OPTIONS },
ide_in_use: { id: 'ide_in_use', label: 'IDE-er i bruk', type: 'multiSelect', from: 'shared', shared_path: 'platform.ide_list', options: IDE_OPTIONS },
ci_system: { id: 'ci_system', label: 'CI/CD-system', type: 'select', from: 'shared', shared_path: 'platform.ci_system', options: CI_OPTIONS }
};
const TARGET_TYPES = ['codebase', 'plugin', 'mcp-server', 'ide-extension', 'github-url'];
const SCENARIOS = [
{ id: 'pre-deploy', name: 'Pre-deploy security-gate' },
{ id: 'continuous-monitor', name: 'Kontinuerlig monitorering (watch + diff)' },
{ id: 'plugin-trust', name: 'Trust-vurdering av tredjeparts-plugin' },
{ id: 'mcp-supply-chain', name: 'MCP supply-chain audit' },
{ id: 'ide-extension-risk', name: 'IDE-extension supply-chain risk' },
{ id: 'red-team-baseline', name: 'Red-team baseline mot hooks' },
{ id: 'compliance-audit', name: 'Compliance-audit (OWASP/AI Act)' },
{ id: 'harden-onboarding', name: 'Hardening + grade-A onboarding' }
];
// CATALOG: alle 20 commands. produces_report=true → har parser+renderer
// (implementeres i Fase 2/3). Verktøy-commands har null parser/renderer.
const CATALOG = {
version: '1.0',
generated_for_schema: SCHEMA_VERSION,
categories: [
{ id: 'discover', label: 'Oppdag', count: 7 },
{ id: 'posture', label: 'Posture', count: 4 },
{ id: 'findings-ops', label: 'Findings', count: 4 },
{ id: 'hardening', label: 'Hardening', count: 2 },
{ id: 'adversarial', label: 'Red-team', count: 1 },
{ id: 'mcp-ops', label: 'MCP ops', count: 2 }
],
commands: [
// ===== DISCOVER (7) =====
{
id: 'scan',
category: 'discover',
label: 'Skanning',
description: 'Skann skills/MCP/directories/GitHub repos. Detekterer secrets, injection, supply-chain-risiko, OWASP LLM-mønstre.',
argument_hint: '[path|url] [--deep]',
calls_agent: 'scan-orchestrator + skill-scanner-agent + mcp-scanner-agent',
produces_report: true,
report_archetype: 'risk-score-meter',
report_root_class: 'findings',
renderer: 'renderScan',
input_fields: [
{ id: 'target', label: 'Target (path eller GitHub-URL)', type: 'text', from: 'local', required: true },
{ id: 'deep_mode', label: 'Deep mode (10 deterministiske scannere)', type: 'boolean', from: 'local' },
SHARED.severity_threshold,
{ id: 'branch', label: 'Branch (for GitHub-URL)', type: 'text', from: 'local' },
SHARED.frameworks
]
},
{
id: 'deep-scan',
category: 'discover',
label: 'Deep-scan',
description: '10 deterministiske Node.js scannere — Unicode, entropy, permissions, dep-audit, taint, git-forensics, network, memory, supply-chain-recheck, toxic-flow.',
argument_hint: '[path]',
calls_agent: 'deep-scan-synthesizer-agent',
produces_report: true,
report_archetype: 'findings-grade',
report_root_class: 'small-multiples',
renderer: 'renderDeepScan',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'output_format', label: 'Output-format', type: 'select', from: 'local', options: ['compact', 'json', 'sarif'] },
{ id: 'fail_on', label: 'Fail-on severity', type: 'select', from: 'local', options: SEVERITY_LEVELS },
{ id: 'baseline_diff', label: 'Diff mot baseline', type: 'boolean', from: 'local' }
]
},
{
id: 'plugin-audit',
category: 'discover',
label: 'Plugin-audit',
description: 'Trust-vurdering av Claude Code plugin (lokal eller GitHub URL). Sjekker permissions, hooks, agents, signatur.',
argument_hint: '[path|url]',
calls_agent: 'skill-scanner-agent + posture-assessor-agent',
produces_report: true,
report_archetype: 'risk-score-meter',
report_root_class: 'verdict-pill-lg',
renderer: 'renderPluginAudit',
input_fields: [
{ id: 'target', label: 'Plugin-path eller GitHub-URL', type: 'text', from: 'local', required: true },
{ id: 'install_intent', label: 'Skal installeres etter audit?', type: 'boolean', from: 'local' },
SHARED.strict_mode
]
},
{
id: 'mcp-audit',
category: 'discover',
label: 'MCP config-audit',
description: 'Audit alle installerte MCP server-konfigurasjoner. Permissions, trust, network exposure.',
argument_hint: '[--live]',
calls_agent: 'mcp-scanner-agent',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderMcpAudit',
input_fields: [
{ id: 'live_inspection', label: 'Live-inspeksjon (JSON-RPC mot kjørende servere)', type: 'boolean', from: 'local' },
{ id: 'config_paths', label: 'Config-stier (én per linje)', type: 'textarea', from: 'local' }
]
},
{
id: 'mcp-inspect',
category: 'discover',
label: 'MCP live-inspect',
description: 'Koble til kjørende MCP-servere og skann tool-deskripsjoner for injection/shadowing/drift.',
argument_hint: '[server-url eller name]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderMcpInspect',
input_fields: [
{ id: 'target_servers', label: 'Server-navn (én per linje, tom = alle)', type: 'textarea', from: 'local' },
{ id: 'timeout_ms', label: 'Timeout (ms)', type: 'number', from: 'local' },
{ id: 'skip_global', label: 'Hopp over globale config-er', type: 'boolean', from: 'local' }
]
},
{
id: 'ide-scan',
category: 'discover',
label: 'IDE-extension-scan',
description: 'Skann installerte VS Code + JetBrains extensions/plugins. 7 VS Code-sjekker + 7 JetBrains-spesifikke sjekker.',
argument_hint: '[target|url]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderIdeScan',
input_fields: [
{ id: 'target', label: 'Target (path, marketplace-URL eller tom for alle installerte)', type: 'text', from: 'local' },
{ id: 'vscode_only', label: 'Kun VS Code', type: 'boolean', from: 'local' },
{ id: 'intellij_only', label: 'Kun JetBrains', type: 'boolean', from: 'local' },
{ id: 'include_builtin', label: 'Inkluder builtins', type: 'boolean', from: 'local' },
{ id: 'online', label: 'Online-modus (Marketplace + OSV.dev)', type: 'boolean', from: 'local' }
]
},
{
id: 'supply-check',
category: 'discover',
label: 'Supply-chain-recheck',
description: 'Re-audit installerte dependencies — lockfiles vs blocklists, OSV.dev CVEs, typosquats.',
argument_hint: '[path]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderSupplyCheck',
input_fields: [
{ id: 'target', label: 'Target path (root med lockfiles)', type: 'text', from: 'local', required: true },
{ id: 'online', label: 'Online OSV.dev-oppslag', type: 'boolean', from: 'local' },
{ id: 'ecosystems', label: 'Ekosystemer', type: 'multiSelect', from: 'local', options: ['npm', 'pip', 'cargo', 'go', 'gem', 'docker', 'brew'] }
]
},
// ===== POSTURE (4) =====
{
id: 'posture',
category: 'posture',
label: 'Posture-quick',
description: 'Rask scorecard på 13/16 kategorier. Inkluderer EU AI Act, NIST AI RMF, ISO 42001 hvis valgt.',
argument_hint: '[path]',
calls_agent: 'posture-scanner.mjs (deterministisk)',
produces_report: true,
report_archetype: 'posture-cards',
report_root_class: 'small-multiples',
renderer: 'renderPosture',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
SHARED.frameworks,
{ id: 'include_compliance_extras', label: 'Inkluder compliance-ekstra (EU AI Act, NIST, ISO)', type: 'boolean', from: 'local' }
]
},
{
id: 'audit',
category: 'posture',
label: 'Full audit (A-F)',
description: 'Full prosjekt-audit med OWASP LLM Top 10-vurdering, scoring og remediation-plan.',
argument_hint: '[path]',
calls_agent: 'posture-assessor-agent',
produces_report: true,
report_archetype: 'findings-grade',
report_root_class: 'radar',
renderer: 'renderAudit',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
SHARED.frameworks,
SHARED.severity_threshold,
{ id: 'include_remediation', label: 'Inkluder remediation-plan', type: 'boolean', from: 'local' }
]
},
{
id: 'dashboard',
category: 'posture',
label: 'Cross-project dashboard',
description: 'Maskinkrysjende dashboard. Posture-skanner per oppdaget Claude Code-prosjekt, aggregert til machine-grade.',
argument_hint: '',
calls_agent: 'dashboard-aggregator.mjs (deterministisk)',
produces_report: true,
report_archetype: 'dashboard-fleet',
report_root_class: 'fleet-grid',
renderer: 'renderDashboard',
input_fields: [
{ id: 'no_cache', label: 'Forbi cache (full re-scan)', type: 'boolean', from: 'local' },
{ id: 'max_depth', label: 'Maks søke-dybde', type: 'number', from: 'local' }
]
},
{
id: 'pre-deploy',
category: 'posture',
label: 'Pre-deploy checklist',
description: 'Pre-deployment sikkerhetssjekkliste — verifiser enterprise-kontroller, compliance, produksjons-readiness.',
argument_hint: '',
calls_agent: 'posture-assessor-agent',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'traffic-light',
renderer: 'renderPreDeploy',
input_fields: [
SHARED.organisation_name,
SHARED.frameworks,
{ id: 'production_environment', label: 'Produksjonsmiljø', type: 'select', from: 'local', options: ['Cloud (Azure)', 'Cloud (AWS)', 'Cloud (GCP)', 'On-prem', 'Hybrid', 'Air-gapped'] },
{ id: 'data_classification', label: 'Dataklassifisering', type: 'select', from: 'local', options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig'] }
]
},
// ===== FINDINGS-OPS (4) =====
{
id: 'diff',
category: 'findings-ops',
label: 'Diff mot baseline',
description: 'Sammenlign scan-resultat mot lagret baseline — viser nye, løste, uendrede og flyttede funn.',
argument_hint: '[path]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'diff-report',
report_root_class: 'diff',
renderer: 'renderDiff',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'baseline_id', label: 'Baseline-ID (tom = siste)', type: 'text', from: 'local' },
{ id: 'show_unchanged', label: 'Vis uendrede funn', type: 'boolean', from: 'local' }
]
},
{
id: 'watch',
category: 'findings-ops',
label: 'Watch (kontinuerlig)',
description: 'Kontinuerlig monitorering — kjør diff på rekursivt intervall via /loop.',
argument_hint: '[path] [--interval 6h]',
calls_agent: 'watch-cron.mjs',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'live-meter',
renderer: 'renderWatch',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'interval', label: 'Intervall', type: 'select', from: 'local', options: ['1h', '4h', '6h', '12h', '24h', '7d'] },
{ id: 'notify_on', label: 'Varsle ved', type: 'multiSelect', from: 'local', options: ['new-findings', 'resolved', 'severity-increase', 'all'] }
]
},
{
id: 'registry',
category: 'findings-ops',
label: 'Skill-registry',
description: 'Skill signature registry — vis stats, skann og registrer skills, søk kjente fingerprints.',
argument_hint: '[scan|search]',
calls_agent: '(deterministisk scanner)',
produces_report: true,
report_archetype: 'findings',
report_root_class: 'findings',
renderer: 'renderRegistry',
input_fields: [
{ id: 'mode', label: 'Modus', type: 'select', from: 'local', options: ['stats', 'scan', 'search'] },
{ id: 'query', label: 'Søkestreng (kun search)', type: 'text', from: 'local' },
{ id: 'target', label: 'Target path (kun scan)', type: 'text', from: 'local' }
]
},
{
id: 'clean',
category: 'findings-ops',
label: 'Clean (auto+semi+manual)',
description: 'Skann og remediere funn — auto-fix deterministiske, bekreft semi-auto med bruker, rapporter manuelle.',
argument_hint: '[path]',
calls_agent: 'cleaner-agent',
produces_report: true,
report_archetype: 'kanban-buckets',
report_root_class: 'kanban',
renderer: 'renderClean',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'auto_apply', label: 'Auto-apply deterministiske fixes', type: 'boolean', from: 'local' },
{ id: 'dry_run', label: 'Dry-run (ingen endringer)', type: 'boolean', from: 'local' },
{ id: 'interactive', label: 'Interaktiv bekreftelse for semi-auto', type: 'boolean', from: 'local' }
]
},
// ===== HARDENING (2) =====
{
id: 'harden',
category: 'hardening',
label: 'Harden (Grade A config)',
description: 'Generer Grade A sikkerhetskonfigurasjon — settings.json, CLAUDE.md security-seksjon, .gitignore.',
argument_hint: '[path]',
calls_agent: '(deterministisk generator)',
produces_report: true,
report_archetype: 'diff-report',
report_root_class: 'diff',
renderer: 'renderHarden',
input_fields: [
{ id: 'target', label: 'Target path', type: 'text', from: 'local', required: true },
{ id: 'project_type', label: 'Prosjekt-type', type: 'select', from: 'local', options: ['plugin', 'monorepo', 'standalone', 'auto-detect'] },
{ id: 'apply', label: 'Anvend endringene direkte', type: 'boolean', from: 'local' },
{ id: 'skip_existing', label: 'Hopp over filer som allerede er Grade A', type: 'boolean', from: 'local' }
]
},
{
id: 'threat-model',
category: 'hardening',
label: 'Threat-model (STRIDE/MAESTRO)',
description: 'Interaktiv threat modeling — STRIDE og MAESTRO frameworks for arkitektur-analyse.',
argument_hint: '',
calls_agent: 'threat-modeler-agent',
produces_report: true,
report_archetype: 'matrix-risk',
report_root_class: 'matrix',
renderer: 'renderThreatModel',
input_fields: [
SHARED.organisation_name,
{ id: 'system_name', label: 'System-navn', type: 'text', from: 'local', required: true },
{ id: 'system_description', label: 'System-beskrivelse', type: 'textarea', from: 'local', required: true },
{ id: 'framework', label: 'Framework', type: 'select', from: 'local', options: ['STRIDE', 'MAESTRO', 'STRIDE + MAESTRO'] },
{ id: 'components', label: 'Komponenter (én per linje)', type: 'textarea', from: 'local' }
]
},
// ===== ADVERSARIAL (1) =====
{
id: 'red-team',
category: 'adversarial',
label: 'Red-team simulasjon',
description: '64 attack-scenarier på tvers av 12 kategorier mot plugin hooks. --adaptive for mutasjon-basert evasion.',
argument_hint: '[--category <name>] [--adaptive]',
calls_agent: 'attack-simulator.mjs (data-drevet)',
produces_report: true,
report_archetype: 'red-team-results',
report_root_class: 'risk-meter',
renderer: 'renderRedTeam',
input_fields: [
{ id: 'category', label: 'Kategori (tom = alle 12)', type: 'select', from: 'local', options: ['', 'prompt-injection', 'tool-poisoning', 'data-exfiltration', 'lethal-trifecta', 'mcp-shadowing', 'memory-poisoning', 'supply-chain', 'credential-theft', 'unicode-evasion', 'bash-evasion', 'sub-agent-escape', 'permission-escalation'] },
{ id: 'adaptive', label: 'Adaptive (mutasjon-basert evasion)', type: 'boolean', from: 'local' },
{ id: 'verbose', label: 'Verbose output', type: 'boolean', from: 'local' },
{ id: 'benchmark', label: 'Benchmark-modus', type: 'boolean', from: 'local' }
]
},
// ===== MCP-OPS (2) =====
{
id: 'mcp-baseline-reset',
category: 'mcp-ops',
label: 'MCP-baseline-reset',
description: 'Reset MCP description baseline cache. Etter legitim MCP-server-oppgradering.',
argument_hint: '[--target <tool>] [--list]',
calls_agent: '(deterministisk verktøy)',
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: [
{ id: 'mode', label: 'Modus', type: 'select', from: 'local', options: ['list', 'target', 'clear-all'] },
{ id: 'target_tool', label: 'Tool-navn (kun target)', type: 'text', from: 'local' }
]
},
{
id: 'security',
category: 'mcp-ops',
label: 'Security-router',
description: 'Router-kommando — viser tilgjengelige sub-commands. Verktøy for navigasjon, ingen rapport.',
argument_hint: '',
calls_agent: '(router)',
produces_report: false,
report_archetype: null,
report_root_class: null,
renderer: null,
input_fields: []
}
]
};
window.__CATALOG = CATALOG;
window.__SHARED = SHARED;
window.__SCENARIOS = SCENARIOS;
// ============================================================
// COMMAND FORM RENDERER + buildCommand
// ============================================================
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 = {};
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;
}
});
}
Object.keys(formData).forEach(function (k) {
const v = formData[k];
if (isFilledArg(v)) args[k] = v;
else delete args[k];
});
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 = ['/security:' + commandId];
orderedKeys.forEach(function (k) {
parts.push(k + '=' + serializeArgValue(args[k]));
});
return parts.join(' ');
}
window.__buildCommand = buildCommand;
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 requiredMark = field.required ? '<span class="required-mark" aria-label="påkrevd">*</span>' : '';
const labelHtml = '<label for="' + domId + '" class="field-label">' + escapeHtml(field.label) + requiredMark + 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 {
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 + ' felt' + (fieldCount === 1 ? '' : 'er') + ' (' + 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; });
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
// ============================================================
function getSurfaceEl(name) {
return document.querySelector('[data-surface="' + name + '"]');
}
function showSurface(name) {
const surfaces = document.querySelectorAll('main#app > [data-surface]');
for (let i = 0; i < surfaces.length; i++) {
surfaces[i].hidden = (surfaces[i].dataset.surface !== name);
}
}
let renderQueued = false;
function scheduleRender() {
if (renderQueued) return;
renderQueued = true;
queueMicrotask(function () {
renderQueued = false;
renderActive();
});
}
function renderActive() {
if (!store) return;
const active = store.state.activeSurface || 'home';
showSurface(active);
if (active === 'onboarding') renderOnboardingSurface();
else if (active === 'home') renderHomeSurface();
else if (active === 'project') renderProjectSurface();
else if (active === 'catalog') renderCatalogSurface();
}
function navigate(surface) {
store.state.activeSurface = surface;
scheduleRender();
}
// ============================================================
// TOPBAR (felles for home/catalog/project)
// ============================================================
function renderTopbar(crumb) {
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const breadcrumbInner = (orgName ? escapeHtml(orgName) : '') + (orgName && crumb ? ' · ' : '') + (crumb || '');
const breadcrumbHtml = breadcrumbInner
? '<nav class="app-header__breadcrumb" aria-label="Brødsmuler">' + breadcrumbInner + '</nav>'
: '';
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const themeLabel = currentTheme === 'light' ? 'Lys' : 'Mørk';
const themeNext = currentTheme === 'light' ? 'mørk' : 'lys';
return (
'<header class="app-header">' +
'<div class="app-header__brand">' +
'<span class="app-header__brand-mark" aria-hidden="true">S</span>' +
'<span>llm-security</span>' +
'</div>' +
breadcrumbHtml +
'<div class="app-header__spacer"></div>' +
'<div class="app-header__actions" role="group" aria-label="Hovednavigasjon">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">Hjem</button>' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-catalog">Katalog</button>' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding">Re-onboard</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="export-state" aria-label="Eksporter state til JSON">Eksporter</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="import-state" aria-label="Importer state fra JSON">Importer</button>' +
'<input type="file" accept="application/json,.json" data-import-input hidden>' +
'<button type="button" class="theme-toggle" data-action="toggle-theme" aria-label="Bytt til ' + themeNext + ' modus">' +
'<span data-theme-label>' + themeLabel + '</span>' +
'</button>' +
'</div>' +
'</header>'
);
}
// ============================================================
// ONBOARDING SURFACE
// ============================================================
let onboardingActiveStep = 'organization';
const ONBOARDING_GROUPS = [
{
id: 'organization',
label: 'Organisasjon',
fields: [
{ id: 'organization.name', label: 'Virksomhet', type: 'text', required: true },
{ id: 'organization.sector', label: 'Sektor', type: 'select', options: SECTOR_OPTIONS, required: true },
{ id: 'organization.size', label: 'Antall ansatte', type: 'text' },
{ id: 'organization.description', label: 'Kort beskrivelse', type: 'textarea' }
]
},
{
id: 'scope',
label: 'Scope',
fields: [
{ id: 'scope.typical_paths', label: 'Typiske scan-targets (paths, kommaseparert eller én per linje)', type: 'textarea' },
{ id: 'scope.exclude_patterns', label: 'Exclude-patterns (kommaseparert eller én per linje)', type: 'textarea' },
{ id: 'scope.github_orgs', label: 'GitHub-orgs (kommaseparert)', type: 'text' },
{ id: 'scope.mcp_servers', label: 'MCP-servere i bruk', type: 'multiSelect', options: ['filesystem', 'github', 'memory', 'fetch', 'sqlite', 'postgres', 'puppeteer', 'sequentialthinking', 'time', 'weather', 'Annet'] }
]
},
{
id: 'profile',
label: 'Profil',
fields: [
{ id: 'profile.severity_threshold', label: 'Severity-terskel for fail', type: 'select', options: SEVERITY_LEVELS, required: true },
{ id: 'profile.strict_mode', label: 'Strict mode', type: 'boolean' },
{ id: 'profile.ci_failon', label: 'CI fail-on severity', type: 'select', options: SEVERITY_LEVELS },
{ id: 'profile.suppress_categories', label: 'Suppress kategorier', type: 'multiSelect', options: SUPPRESS_CATEGORIES }
]
},
{
id: 'platform',
label: 'Plattform',
fields: [
{ id: 'platform.ide_list', label: 'IDE-er i bruk', type: 'multiSelect', options: IDE_OPTIONS },
{ id: 'platform.mcp_count', label: 'Antall MCP-servere konfigurert', type: 'number' },
{ id: 'platform.ci_system', label: 'CI/CD-system', type: 'select', options: CI_OPTIONS },
{ id: 'platform.runtime_envs', label: 'Runtime-miljøer', type: 'multiSelect', options: RUNTIME_OPTIONS }
]
},
{
id: 'compliance',
label: 'Compliance',
fields: [
{ id: 'compliance.frameworks', label: 'Compliance-rammeverk', type: 'multiSelect', options: FRAMEWORK_OPTIONS },
{ id: 'compliance.datatilsynet_consulted', label: 'Datatilsynet konsultert', type: 'boolean' },
{ id: 'compliance.gdpr_role', label: 'GDPR-rolle', type: 'select', options: ['controller', 'processor', 'joint-controller', 'usikker'] },
{ id: 'compliance.ai_act_role', label: 'AI Act-rolle', type: 'select', options: ['provider', 'deployer', 'distributor', 'importer', 'usikker'] }
]
}
];
function getOnboardingValue(path) {
const parts = path.split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length; i++) {
if (cur == null) return undefined;
cur = cur[parts[i]];
}
return cur;
}
function setOnboardingValue(path, value) {
const parts = path.split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function isOnboardingGroupComplete(group) {
return group.fields.every(function (f) {
if (!f.required) return true;
const v = getOnboardingValue(f.id);
if (f.type === 'multiSelect') return Array.isArray(v) && v.length > 0;
if (f.type === 'boolean') return v === true || v === false;
return v != null && String(v).trim() !== '';
});
}
function renderOnboardingProgress() {
const items = ONBOARDING_GROUPS.map(function (g, i) {
const isActive = onboardingActiveStep === g.id;
const done = isOnboardingGroupComplete(g);
const cls = 'form-progress__step' + (done ? ' form-progress__step--done' : '');
const ariaCurrent = isActive ? ' aria-current="step"' : '';
const marker = done ? '✓' : (i + 1);
return (
'<button type="button" class="' + cls + '"' + ariaCurrent + ' data-action="onboarding-step" data-step="' + escapeAttr(g.id) + '">' +
'<span class="form-progress__step-marker" aria-hidden="true">' + marker + '</span>' +
'<span>' + escapeHtml(g.label) + '</span>' +
'</button>'
);
}).join('');
return '<aside class="form-progress" aria-label="Onboarding-fremdrift"><h2 class="form-progress__heading">Trinn</h2>' + items + '</aside>';
}
function renderOnboardingFieldRow(field, scope) {
const domId = 'ob-' + scope + '-' + field.id.replace(/\./g, '-');
const value = getOnboardingValue(field.id);
const fieldDef = {
id: field.id,
label: field.label,
type: field.type,
from: 'local',
options: field.options,
required: field.required
};
// Reuse the command form field renderer with onboarding-specific data-attrs
const html = renderCommandFormField(fieldDef, domId, value);
return html.replace('data-cf-field="' + escapeAttr(field.id) + '"', 'data-cf-field="' + escapeAttr(field.id) + '" data-onboarding-field="1"');
}
function renderOnboardingSurface() {
const root = getSurfaceEl('onboarding');
if (!root) return;
const group = ONBOARDING_GROUPS.find(function (g) { return g.id === onboardingActiveStep; }) || ONBOARDING_GROUPS[0];
const fieldsHtml = group.fields.map(function (f) { return renderOnboardingFieldRow(f, 'main'); }).join('');
const allCompleteCount = ONBOARDING_GROUPS.filter(isOnboardingGroupComplete).length;
const isLast = ONBOARDING_GROUPS[ONBOARDING_GROUPS.length - 1].id === group.id;
const isFirst = ONBOARDING_GROUPS[0].id === group.id;
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const isReturning = !!orgName;
const headerHtml = (
'<header class="onboarding-header">' +
'<span class="eyebrow">' + (isReturning ? 'RE-ONBOARDING' : 'ONBOARDING') + ' · ' + allCompleteCount + ' av ' + ONBOARDING_GROUPS.length + ' grupper komplette</span>' +
'<h1>' + (isReturning ? 'Oppdater fellesfeltene' : 'Velkommen — la oss sette opp llm-security for ' + (orgName || 'din virksomhet')) + '</h1>' +
'<p>Disse 5 gruppene er felles state. De forhåndsutfyller alle command-skjemaer for nye prosjekter, så du slipper å re-skrive samme info.</p>' +
'</header>'
);
const stepNavHtml = (
'<div class="onboarding-actions">' +
(isFirst ? '' : '<button type="button" class="btn btn--ghost" data-action="onboarding-prev">← Forrige</button>') +
(isLast
? '<button type="button" class="btn btn--primary" data-action="onboarding-finish">Ferdig — gå til hjem</button>'
: '<button type="button" class="btn btn--primary" data-action="onboarding-next">Neste →</button>'
) +
'<span class="onboarding-help">Tipset: alle felter kan endres senere via Re-onboard.</span>' +
'</div>'
);
root.innerHTML = (
renderTopbar(isReturning ? 'Re-onboarding' : 'Onboarding') +
'<div class="app-shell">' +
headerHtml +
'<div class="onboarding-layout">' +
renderOnboardingProgress() +
'<form class="onboarding-fields" data-onboarding-form="' + escapeAttr(group.id) + '" autocomplete="off" onsubmit="return false;">' +
'<h2 style="margin: 0 0 var(--space-3); font-size: var(--font-size-xl);">' + escapeHtml(group.label) + '</h2>' +
fieldsHtml +
stepNavHtml +
'</form>' +
'</div>' +
'</div>'
);
}
// ============================================================
// HOME SURFACE
// ============================================================
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 inferProjectVerdict(project) {
const reports = (project && project.reports) || {};
const verdicts = [];
for (const k in reports) {
const v = reports[k] && reports[k].parsed && reports[k].parsed.verdict;
if (v) verdicts.push(String(v).toLowerCase());
}
if (verdicts.length === 0) return 'n-a';
for (let i = 0; i < verdicts.length; i++) {
if (verdicts[i] === 'block' || verdicts[i] === 'failed') return 'block';
}
for (let i = 0; i < verdicts.length; i++) {
const v = verdicts[i];
if (v === 'go-with-conditions' || v === 'warning') return 'go-with-conditions';
}
let allGo = true;
for (let i = 0; i < verdicts.length; i++) {
const v = verdicts[i];
if (v !== 'go' && v !== 'approved' && v !== 'allow') { allGo = false; break; }
}
return allGo ? 'approved' : 'n-a';
}
function inferProjectLastUpdated(project) {
const reports = (project && project.reports) || {};
let latest = null;
for (const k in reports) {
const r = reports[k];
if (r && r.updatedAt) { if (!latest || r.updatedAt > latest) latest = r.updatedAt; }
}
const ts = latest || (project && project.createdAt) || '';
return ts ? String(ts).slice(0, 10) : '';
}
function projectMeterBand(filled, total) {
if (total === 0) return '4';
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" data-action="goto-onboarding">' +
'<span class="tracks__card-icon" aria-hidden="true">⚙︎</span>' +
'<h3 class="tracks__card-title">Re-onboard</h3>' +
'<p class="tracks__card-desc">Oppdater fellesfeltene 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" 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 nytt sikkerhetsprosjekt — codebase, plugin, MCP-server, IDE-extension eller GitHub-URL.</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" 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 ' + CATALOG.commands.length + ' 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 prosjekt for å starte sikkerhetsskanning og auditing. Eller last inn demo-data for å se hvordan det ser ut.</p>' +
'<div class="guide-panel__action" style="display:flex; gap: var(--space-2); flex-wrap: wrap;">' +
'<button type="button" class="btn btn--primary" data-action="new-project">Opprett første prosjekt</button>' +
'<button type="button" class="btn btn--secondary" data-action="load-demo">Last inn demo-data</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 scenarioName = scenarios.length > 0 ? (SCENARIOS.find(function (s) { return s.id === scenarios[0]; }) || { name: scenarios[0] }).name : '';
const chip = scenarios.length > 0
? '<span class="fleet-tile__chip" title="' + escapeAttr(scenarioName) + '">' + escapeHtml(scenarioName.length > 24 ? scenarioName.slice(0, 22) + '…' : scenarioName) + (scenarios.length > 1 ? ' +' + (scenarios.length - 1) : '') + '</span>'
: '<span class="fleet-tile__chip">Uten scenario</span>';
const targetTypeLabel = (p.target_type || 'codebase').replace('-', ' ');
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>' + escapeHtml(targetTypeLabel) + ' · ' + filled + '/' + reportTotal + ' rapporter</span>' +
'<span>' + pct + '%</span>' +
'</div>' +
'</button>'
);
}).join('');
return '<div class="fleet-grid">' + tiles + '</div>';
})();
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const activeReportCount = projects.reduce(function (a, p) { return a + projectReportCount(p); }, 0);
const homeShell = renderPageShell({
eyebrow: 'HJEM',
title: 'Hei, ' + (orgName || 'venn'),
lede: orgName
? 'Velg arbeidsspor eller utforsk eksisterende prosjekter. Felles state er aktiv og forhåndsutfyller skjemaer.'
: 'Single-file sikkerhetsskanning + auditing for Claude Code-prosjekter. Start med onboarding for å aktivere felles state.',
verdict: 'n-a',
keyStats: [
{ label: 'PROSJEKTER', value: projects.length },
{ label: 'AKTIVE RAPPORTER', value: activeReportCount },
{ label: 'KOMMANDOER', value: CATALOG.commands.length }
]
},
'<div class="stack-lg">' +
tracksHtml +
'<section class="home-projects">' +
'<span class="eyebrow">PROSJEKTER · ' + projects.length + '</span>' +
'<div class="home-section-head">' +
'<h2>Mine prosjekter</h2>' +
'<span class="home-section-meta">' + projects.length + ' prosjekt' + (projects.length === 1 ? '' : 'er') + ' · maks ' + reportTotal + ' rapporter per prosjekt</span>' +
'</div>' +
projectListHtml +
(projects.length > 0 ? '<div class="onboarding-actions" style="margin-top: var(--space-4);"><button type="button" class="btn btn--primary" data-action="new-project">Nytt prosjekt</button> <button type="button" class="btn btn--secondary" data-action="load-demo">Last inn demo-data (overskriver)</button></div>' : '') +
'</section>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Hjem') +
'<div class="app-shell">' + homeShell + '</div>'
);
}
// ============================================================
// CATALOG SURFACE
// ============================================================
let catalogSearchQuery = '';
function catalogMatches(cmd, q) {
if (!q) return true;
const hay = ((cmd.id || '') + ' ' + (cmd.label || '') + ' ' + (cmd.description || '') + ' ' + (cmd.argument_hint || '')).toLowerCase();
return hay.indexOf(q) >= 0;
}
function renderCatalogCardHtml(cmd) {
const isVerktoy = !cmd.produces_report;
const pill = isVerktoy ? '<span class="card__pill">Verktøy</span>' : '<span class="card__pill">Rapport</span>';
const hintHtml = cmd.argument_hint ? '<span class="card__hint">' + escapeHtml(cmd.argument_hint) + '</span>' : '';
const verktoyNotice = isVerktoy ? '<div class="catalog-tool-notice">Verktøy — ingen rapport-import. Skjema bygger pipeline-streng som kjøres i terminalen.</div>' : '';
return (
'<article class="card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
'<div class="card__head">' +
'<div>' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
hintHtml +
'</div>' +
pill +
'</div>' +
verktoyNotice +
'<div class="card__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="catalog-open-form" data-command="' + escapeAttr(cmd.id) + '">Åpne skjema</button>' +
'<span style="font-size: var(--font-size-xs); color: var(--color-text-tertiary);">' + (cmd.input_fields || []).length + ' felter</span>' +
'</div>' +
'</article>'
);
}
function renderCatalogGroupsHtml() {
const q = catalogSearchQuery.toLowerCase().trim();
return CATALOG.categories.map(function (cat) {
const cmds = CATALOG.commands.filter(function (c) { return c.category === cat.id && catalogMatches(c, q); });
if (cmds.length === 0 && q) return ''; // skjul tomme grupper ved aktiv søk
const isOpen = q !== '' || cat.id === 'discover'; // discover åpen som default
const cardsHtml = cmds.length > 0
? '<div class="catalog-cards-grid">' + cmds.map(renderCatalogCardHtml).join('') + '</div>'
: '<p style="color: var(--color-text-tertiary); margin: var(--space-3) 0;">Ingen kommandoer i denne kategorien.</p>';
return (
'<div class="expansion" aria-expanded="' + (isOpen ? 'true' : 'false') + '">' +
'<button type="button" class="expansion__head" data-action="catalog-toggle-group" data-group="' + escapeAttr(cat.id) + '">' +
'<span class="expansion__title">' +
'<span class="expansion__title-main">' + escapeHtml(cat.label) + '</span>' +
'<span class="expansion__title-sub">' + cmds.length + ' av ' + cat.count + ' kommandoer' + (q ? ' (filtrert)' : '') + '</span>' +
'</span>' +
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
'</button>' +
'<div class="expansion__body">' + cardsHtml + '</div>' +
'</div>'
);
}).join('');
}
function renderCatalogSurface() {
const root = getSurfaceEl('catalog');
if (!root) return;
const total = CATALOG.commands.length;
const reportCount = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
const toolCount = total - reportCount;
const catalogShell = renderPageShell({
eyebrow: 'KATALOG',
title: 'Command-katalog',
lede: 'Alle ' + total + ' kommandoer gruppert på kategori. Bygg pipeline-strenger uten et aktivt prosjekt.',
verdict: 'n-a',
keyStats: [
{ label: 'TOTALT', value: total },
{ label: 'RAPPORT-KOMMANDOER', value: reportCount },
{ label: 'VERKTØY', value: toolCount }
]
},
'<div class="stack-lg">' +
'<input type="search" class="input catalog-search" placeholder="Søk i kommandoer (id, label, beskrivelse, argument-hint) …" data-catalog-search value="' + escapeAttr(catalogSearchQuery) + '" aria-label="Søk i kommando-katalogen">' +
'<div data-catalog-groups>' + renderCatalogGroupsHtml() + '</div>' +
'</div>'
);
root.innerHTML = (
renderTopbar('Katalog') +
'<div class="app-shell">' + catalogShell + '</div>'
);
// Bevarer fokus i søkefeltet under re-render
const searchEl = root.querySelector('[data-catalog-search]');
if (searchEl && document.activeElement !== searchEl && catalogSearchQuery) {
// Ikke stjel fokus med mindre brukeren akkurat skrev — håndteres i action handler
}
}
// ============================================================
// PROJECT SURFACE (stub i Fase 1 — full report-render i Fase 2/3)
// ============================================================
let currentProjectTab = 'discover';
let currentProjectScreen = 'rapporter';
function renderCommandSubCard(cmd, projectId) {
const project = findProject(projectId);
const report = project && project.reports && project.reports[cmd.id];
const hasReport = !!(report && report.parsed);
const formZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Skjema</h4>' +
renderCommandForm(cmd.id, { projectId: projectId, scope: 'p' }) +
'</div>'
);
let pasteZone = '';
let reportZone = '';
if (cmd.produces_report) {
const sampleHint = 'Lim inn output fra <code>' + escapeHtml('/security ' + cmd.id) + '</code> her, eller bruk fixture-import (Fase 2/3).';
pasteZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Paste-import</h4>' +
'<div class="paste-import-row" data-paste-import="' + escapeAttr(cmd.id) + '" data-project-id="' + escapeAttr(projectId) + '">' +
'<textarea class="textarea" rows="4" placeholder="Lim inn markdown-output fra slash-kommandoen…" data-paste-text></textarea>' +
'<div class="paste-import-row__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="parse-paste" data-command="' + escapeAttr(cmd.id) + '">Parse og rendre</button>' +
(hasReport ? '<button type="button" class="btn btn--ghost btn--sm" data-action="clear-report" data-command="' + escapeAttr(cmd.id) + '">Fjern rapport</button>' : '') +
'<span style="font-size: var(--font-size-xs); color: var(--color-text-tertiary);">' + sampleHint + '</span>' +
'</div>' +
'</div>' +
'</div>'
);
reportZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Rapport</h4>' +
'<div class="report-slot" data-report-slot="' + escapeAttr(cmd.id) + '"></div>' +
'</div>'
);
} else {
reportZone = (
'<div class="sub-zone">' +
'<div class="catalog-tool-notice">Verktøy — denne kommandoen produserer ikke en rapport. Skjemaet bygger en pipeline-streng som kjøres i terminalen.</div>' +
'</div>'
);
}
return (
'<article class="card" data-command-subcard data-command-id="' + escapeAttr(cmd.id) + '">' +
'<div class="card__head">' +
'<div>' +
'<h3 class="card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
(cmd.produces_report
? '<span class="card__pill">' + (hasReport ? '✓ Rapport' : 'Rapport') + '</span>'
: '<span class="card__pill">Verktøy</span>'
) +
'</div>' +
formZone +
pasteZone +
reportZone +
'</article>'
);
}
function renderProjectSurface() {
const root = getSurfaceEl('project');
if (!root) return;
const project = findProject(store.state.activeProjectId);
if (!project) { navigate('home'); return; }
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
const reportFilled = projectReportCount(project);
const actionBar = (
'<div class="onboarding-actions" style="justify-content: flex-end; margin-bottom: var(--space-4);">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">← Tilbake</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett</button>' +
'</div>'
);
const SCREENS = [
{ id: 'oversikt', label: 'Oversikt' },
{ id: 'rapporter', label: 'Rapporter' },
{ id: 'kontekst', label: 'Kontekst' },
{ id: 'eksport', label: 'Eksport' }
];
const screenTabsHtml = '<nav class="tab-list" role="tablist" aria-label="Prosjekt-skjermer">' + SCREENS.map(function (s) {
const isActive = currentProjectScreen === s.id;
return '<button type="button" class="tab" role="tab" aria-current="' + (isActive ? 'true' : 'false') + '" data-action="project-screen" data-screen="' + escapeAttr(s.id) + '">' + escapeHtml(s.label) + '</button>';
}).join('') + '</nav>';
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>';
const panelsHtml = CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
const cards = CATALOG.commands.filter(function (c) { return c.category === cat.id; }).map(function (c) { return renderCommandSubCard(c, project.id); }).join('');
return '<div class="command-cards" role="tabpanel" data-tab-panel="' + escapeAttr(cat.id) + '"' + (isActive ? '' : ' hidden') + '>' + cards + '</div>';
}).join('');
const scenarioChipsList = (project.scenarios || []).map(function (sid) {
const s = SCENARIOS.find(function (x) { return x.id === sid; });
return '<li>' + escapeHtml(s ? s.name : sid) + '</li>';
}).join('');
const oversiktHtml = (
'<div class="tab-panel" data-screen-id="oversikt"' + (currentProjectScreen === 'oversikt' ? '' : ' hidden') + '>' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Oversikt</h3>' +
'<p class="guide-panel__text">Opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + '. ' + reportFilled + ' av ' + reportTotal + ' rapporter generert.</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);">Target: <code>' + escapeHtml(project.target_path || '—') + '</code> (<em>' + escapeHtml(project.target_type || 'codebase') + '</em>)</p>' +
(scenarioChipsList ? '<p class="guide-panel__text" style="margin-top: var(--space-2);"><strong>Scenarioer:</strong></p><ul style="margin: 0; padding-left: var(--space-4); color: var(--color-text-secondary);">' + scenarioChipsList + '</ul>' : '') +
'<p class="guide-panel__text" style="margin-top: var(--space-3);"><em>Fase 2-3: aggregert verdict-pille, top-funn på tvers av rapporter, og recommended-next-actions vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const rapporterHtml = '<div class="tab-panel" data-screen-id="rapporter"' + (currentProjectScreen === 'rapporter' ? '' : ' hidden') + '>' + tabsHtml + panelsHtml + '</div>';
const kontekstHtml = (
'<div class="tab-panel" data-screen-id="kontekst"' + (currentProjectScreen === 'kontekst' ? '' : ' hidden') + '>' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Kontekst</h3>' +
'<p class="guide-panel__text">Fellesfeltene fra onboarding gjenbrukes automatisk i alle command-skjemaer. Bruk <button type="button" class="btn btn--ghost btn--sm" data-action="goto-onboarding" style="display:inline;">Re-onboard</button> for å oppdatere.</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);"><em>Fase 2-3: snapshot av de 5 fellesgruppene og hvilke felt som prefilles per kommando vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const eksportHtml = (
'<div class="tab-panel" data-screen-id="eksport"' + (currentProjectScreen === 'eksport' ? '' : ' hidden') + '>' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Eksport</h3>' +
'<p class="guide-panel__text">Bruk <strong>Eksporter</strong> i toppmenyen for hele state. Per-prosjekt PDF/Markdown-eksport kommer i Fase 3.</p>' +
'</div>' +
'</div>' +
'</div>'
);
const projectShell = renderPageShell({
eyebrow: 'PROSJEKT · ' + escapeHtml((project.target_type || 'codebase').toUpperCase()),
title: project.name,
lede: project.description || '',
verdict: inferProjectVerdict(project),
keyStats: [
{ label: 'RAPPORTER', value: reportFilled + '/' + reportTotal },
{ label: 'SIST OPPDATERT', value: inferProjectLastUpdated(project) },
{ label: 'TARGET', value: (project.target_type || 'codebase') }
]
},
'<div class="stack-lg">' + actionBar + screenTabsHtml + oversiktHtml + rapporterHtml + kontekstHtml + eksportHtml + '</div>'
);
root.innerHTML = renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
'<div class="app-shell app-shell--wide">' + projectShell + '</div>';
queueMicrotask(rehydratePasteImports);
}
// ============================================================
// PAGE SHELL + VERDICT-PILL + KEY-STATS
// ============================================================
function renderVerdictPill(verdict) {
const v = String(verdict || 'n-a').toLowerCase();
const labels = {
'go': 'GO',
'go-with-conditions': 'BETINGET',
'block': 'BLOKKERT',
'approved': 'GODKJENT',
'failed': 'UNDERKJENT',
'allow': 'TILLATT',
'warning': 'ADVARSEL',
'n-a': 'IKKE VURDERT'
};
return '<span class="verdict-pill" data-verdict="' + escapeAttr(v) + '">' + escapeHtml(labels[v] || v.toUpperCase()) + '</span>';
}
function renderKeyStatsGrid(stats) {
if (!stats || !stats.length) return '';
const items = stats.map(function (s) {
const cls = 'key-stat' + (s.modifier ? ' key-stat--' + escapeAttr(s.modifier) : '');
const hint = s.hint ? '<span class="key-stat__hint">' + escapeHtml(s.hint) + '</span>' : '';
return '<div class="' + cls + '">' +
'<span class="key-stat__label">' + escapeHtml(s.label || '') + '</span>' +
'<span class="key-stat__value">' + escapeHtml(String(s.value)) + '</span>' +
hint +
'</div>';
}).join('');
return '<div class="key-stats">' + items + '</div>';
}
function renderPageShell(opts, bodyHtml) {
opts = opts || {};
const eyebrow = opts.eyebrow ? '<span class="page__eyebrow">' + escapeHtml(opts.eyebrow) + '</span>' : '';
const title = '<h1 class="page__title">' + escapeHtml(opts.title || '') + '</h1>';
const lede = opts.lede ? '<p class="page__lede">' + escapeHtml(opts.lede) + '</p>' : '';
const verdict = (opts.verdict && opts.verdict !== 'n-a') ? renderVerdictPill(opts.verdict) : '';
const aside = verdict ? '<div class="page__header-aside">' + verdict + '</div>' : '';
const stats = renderKeyStatsGrid(opts.keyStats);
return (
'<header class="page__header">' +
'<div class="page__header-main">' + eyebrow + title + lede + '</div>' +
aside +
'</header>' +
stats +
(bodyHtml || '')
);
}
window.__renderPageShell = renderPageShell;
window.__renderVerdictPill = renderVerdictPill;
window.__renderKeyStatsGrid = renderKeyStatsGrid;
// ============================================================
// INFER VERDICT + KEY-STATS PER ARCHETYPE
// (Fase 2/3 utvider med flere archetypes)
// ============================================================
function normalizeVerdict(v) {
const s = String(v || '').toLowerCase().trim();
const map = {
'block': 'block', 'blokk': 'block', 'blokkert': 'block', 'failed': 'failed', 'underkjent': 'failed',
'warning': 'warning', 'advarsel': 'warning',
'go-with-conditions': 'go-with-conditions', 'betinget': 'go-with-conditions', 'conditional': 'go-with-conditions',
'go': 'go', 'tillatt': 'allow', 'allow': 'allow', 'approved': 'approved', 'godkjent': 'approved',
'n-a': 'n-a', 'na': 'n-a', 'ikke-vurdert': 'n-a'
};
return map[s] || s || 'n-a';
}
function inferVerdict(data, archetype) {
if (!data) return 'n-a';
if (data.verdict) return normalizeVerdict(data.verdict);
switch (archetype) {
case 'findings': {
const fs = data.findings || [];
if (!fs.length) return 'allow';
const crit = fs.some(function (f) { return /crit|kritisk/i.test(f.severity || ''); });
return crit ? 'block' : 'warning';
}
case 'findings-grade': {
const g = String(data.grade || '').toUpperCase();
if (g === 'A' || g === 'B') return 'allow';
if (g === 'C' || g === 'D') return 'warning';
if (g === 'F') return 'block';
return 'n-a';
}
case 'posture-cards': {
const g = String(data.grade || '').toUpperCase();
if (g === 'A' || g === 'B') return 'allow';
if (g === 'C' || g === 'D') return 'warning';
if (g === 'F') return 'block';
return 'n-a';
}
case 'risk-score-meter': {
const score = Number(data.risk_score);
if (isNaN(score)) return 'n-a';
if (score >= 65) return 'block';
if (score >= 15) return 'warning';
return 'allow';
}
case 'dashboard-fleet': {
const g = String(data.machine_grade || '').toUpperCase();
if (g === 'A' || g === 'B') return 'allow';
if (g === 'C' || g === 'D') return 'warning';
if (g === 'F') return 'block';
return 'n-a';
}
case 'red-team-results': {
const fail = Number(data.fail_count) || 0;
if (fail > 5) return 'block';
if (fail > 0) return 'warning';
return 'allow';
}
case 'diff-report': {
const newCount = (data['new'] || []).length;
if (newCount > 0) return 'warning';
return 'allow';
}
case 'kanban-buckets': {
const remove = (data.remove || []).length;
if (remove > 0) return 'warning';
return 'allow';
}
case 'matrix-risk': {
const threats = data.threats || data.findings || [];
const hasCritical = threats.some(function (t) { return /crit|kritisk/i.test(t.severity || ''); });
if (hasCritical) return 'block';
if (threats.length) return 'warning';
return 'n-a';
}
default:
return 'n-a';
}
}
const KEY_STATS_CONFIG = {
'findings': function (d) {
const fs = d.findings || [];
const crit = fs.filter(function (f) { return /crit|kritisk/i.test(f.severity || ''); }).length;
const high = fs.filter(function (f) { return /^high|^høy/i.test(f.severity || ''); }).length;
return [
{ label: 'TOTALT', value: fs.length },
{ label: 'KRITISK', value: crit, modifier: crit > 0 ? 'crit' : null },
{ label: 'HØY', value: high, modifier: high > 0 ? 'high' : null }
];
},
'findings-grade': function (d) {
const out = [];
if (d.grade) out.push({ label: 'GRADE', value: String(d.grade).toUpperCase(), modifier: /a|b/i.test(d.grade) ? 'low' : (/c|d/i.test(d.grade) ? 'med' : 'crit') });
if (d.score != null) out.push({ label: 'SCORE', value: d.score });
if (d.findings) out.push({ label: 'FUNN', value: d.findings.length });
return out;
},
'risk-score-meter': function (d) {
const out = [];
if (d.risk_score != null) {
const mod = d.risk_score >= 65 ? 'crit' : (d.risk_score >= 15 ? 'med' : 'low');
out.push({ label: 'RISK SCORE', value: d.risk_score, modifier: mod });
}
if (d.riskBand) out.push({ label: 'BAND', value: d.riskBand });
return out;
},
'red-team-results': function (d) {
return [
{ label: 'TOTALT', value: d.total || 0 },
{ label: 'PASS', value: d.pass_count || 0, modifier: 'low' },
{ label: 'FAIL', value: d.fail_count || 0, modifier: (d.fail_count > 0 ? 'crit' : null) }
];
},
'dashboard-fleet': function (d) {
return [
{ label: 'PROSJEKTER', value: (d.projects || []).length },
{ label: 'MASKINKLASSE', value: String(d.machine_grade || 'n/a').toUpperCase() },
{ label: 'SVAKEST', value: d.weakest_link || '' }
];
}
};
function inferKeyStats(data, archetype) {
if (!data) return [];
if (Array.isArray(data.keyStats)) return data.keyStats;
const fn = KEY_STATS_CONFIG[archetype];
if (typeof fn !== 'function') return [];
try {
const out = fn(data);
return Array.isArray(out) ? out : [];
} catch (e) { return []; }
}
window.__inferVerdict = inferVerdict;
window.__inferKeyStats = inferKeyStats;
window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG;
// ============================================================
// DATA-VERSION MIGRATION (mirror av ms-ai-architect v1->v2)
// ============================================================
function migrateDataVersion(state, archetypeFor) {
if (!state) return state;
if (state.dataVersion === 2) return state;
const projects = state.projects || [];
for (let i = 0; i < projects.length; i++) {
const reports = (projects[i] && projects[i].reports) || {};
const ids = Object.keys(reports);
for (let j = 0; j < ids.length; j++) {
const cmdId = ids[j];
const r = reports[cmdId];
if (!r || !r.parsed) continue;
const arche = typeof archetypeFor === 'function' ? archetypeFor(cmdId) : null;
if (!arche) continue;
if (r.parsed.verdict == null) r.parsed.verdict = inferVerdict(r.parsed, arche);
if (!Array.isArray(r.parsed.keyStats)) r.parsed.keyStats = inferKeyStats(r.parsed, arche);
}
}
state.dataVersion = 2;
return state;
}
function defaultArchetypeFor(commandId) {
const cmds = (CATALOG && CATALOG.commands) || [];
for (let i = 0; i < cmds.length; i++) {
if (cmds[i].id === commandId) return cmds[i].report_archetype || null;
}
return null;
}
window.__migrateDataVersion = migrateDataVersion;
window.__defaultArchetypeFor = defaultArchetypeFor;
// ============================================================
// PARSERS + RENDERERS — Fase 1 har KUN routing-objekt + placeholder.
// Fase 2 implementerer 10 høy-prio parsere/renderere.
// Fase 3 implementerer resterende 10.
// ============================================================
const PARSERS = {}; // { 'scan': function(md) -> {ok, data} | {ok:false, errors} }
const RENDERERS = {}; // { 'renderScan': function(data, slot) }
window.__PARSERS = PARSERS;
window.__RENDERERS = RENDERERS;
function handlePasteImport(commandId, markdown) {
const project = findProject(store.state.activeProjectId);
if (!project) return { ok: false, error: 'Mistet aktivt prosjekt' };
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
if (!cmd) return { ok: false, error: 'Ukjent command: ' + commandId };
if (!cmd.produces_report) return { ok: false, error: 'Verktøy-kommandoer produserer ikke rapport' };
const parser = PARSERS[commandId];
if (typeof parser !== 'function') {
// Fase 1: parsers ikke implementert ennå. Vis placeholder.
const slot = document.querySelector('[data-report-slot="' + CSS.escape(commandId) + '"]');
if (slot) {
slot.innerHTML = (
'<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">Parser ikke implementert ennå</h3>' +
'<p class="guide-panel__text">Kommandoen <code>' + escapeHtml(commandId) + '</code> har ikke parser/renderer i Fase 1. Implementeres i Fase 2 eller 3 (se ARCHITECTURE.local.md, §4 «Kommando-katalog»).</p>' +
'<p class="guide-panel__text" style="margin-top: var(--space-2);">Mottok ' + markdown.length + ' tegn input. Lagret som rå markdown i prosjektets <code>reports.' + escapeHtml(commandId) + '.raw_markdown</code>.</p>' +
'</div>' +
'</div>'
);
}
// Lagre rå markdown (uten parsing) — gir noe state å eksportere
if (!project.reports) project.reports = {};
project.reports[commandId] = {
input: (project.reports[commandId] && project.reports[commandId].input) || {},
raw_markdown: markdown,
parsed: null,
updatedAt: new Date().toISOString()
};
return { ok: false, deferred: true };
}
// Parser finnes — kjør (Fase 2/3)
const result = parser(markdown);
if (!result || result.ok === false) {
const slot = document.querySelector('[data-report-slot="' + CSS.escape(commandId) + '"]');
if (slot) {
const errors = (result && result.errors) || [{ section: 'unknown', reason: 'Ukjent parser-feil' }];
slot.innerHTML = '<div class="error-summary"><h3>Parser-feil</h3><ul>' +
errors.map(function (e) { return '<li><strong>' + escapeHtml(e.section) + ':</strong> ' + escapeHtml(e.reason) + '</li>'; }).join('') +
'</ul></div>';
}
return { ok: false, errors: result && result.errors };
}
// Berik med inferred verdict + key-stats hvis ikke allerede satt
if (result.data.verdict == null) result.data.verdict = inferVerdict(result.data, cmd.report_archetype);
if (!Array.isArray(result.data.keyStats)) result.data.keyStats = inferKeyStats(result.data, cmd.report_archetype);
const renderer = RENDERERS[cmd.renderer];
const slot = document.querySelector('[data-report-slot="' + CSS.escape(commandId) + '"]');
if (!renderer || !slot) {
if (slot) slot.innerHTML = '<div class="error-summary"><h3>Renderer ikke funnet: ' + escapeHtml(cmd.renderer || '(none)') + '</h3></div>';
return { ok: false, error: 'Mangler renderer' };
}
try { renderer(result.data, slot); }
catch (err) {
slot.innerHTML = '<div class="error-summary"><h3>Renderer kastet feil</h3><pre>' + escapeHtml(String(err)) + '</pre></div>';
return { ok: false, error: String(err) };
}
// Lagre i state
if (!project.reports) project.reports = {};
project.reports[commandId] = {
input: (project.reports[commandId] && project.reports[commandId].input) || {},
raw_markdown: markdown,
parsed: result.data,
updatedAt: new Date().toISOString()
};
return { ok: true };
}
function rehydratePasteImports() {
// Re-render eksisterende parsed-rapporter etter en surface-render
const project = findProject(store.state.activeProjectId);
if (!project || !project.reports) return;
Object.keys(project.reports).forEach(function (cmdId) {
const r = project.reports[cmdId];
if (!r || !r.parsed) return;
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
if (!cmd) return;
const renderer = RENDERERS[cmd.renderer];
const slot = document.querySelector('[data-report-slot="' + CSS.escape(cmdId) + '"]');
if (!renderer || !slot) return;
try { renderer(r.parsed, slot); } catch (e) { /* ignorer i rehydrate */ }
});
}
window.__handlePasteImport = handlePasteImport;
window.__rehydratePasteImports = rehydratePasteImports;
// ============================================================
// ACTION HANDLERS (delegated)
// ============================================================
function readFormDataFromCommandForm(commandId) {
const formEl = document.querySelector('form.command-form[data-command-form="' + CSS.escape(commandId) + '"]');
return readCommandFormValues(formEl);
}
function getProjectCommandFormEl(commandId) {
return document.querySelector('form.command-form[data-command-form="' + CSS.escape(commandId) + '"]');
}
async function copyCommandToClipboard(text) {
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) { /* fall through */ }
// Fallback for file:// uten clipboard-API
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
function openModal(html) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = '<div class="modal-backdrop" data-modal-backdrop>' + html + '</div>';
}
function closeModal() {
const root = document.getElementById('modal-root');
if (root) root.innerHTML = '';
}
function renderNewProjectModal() {
const scenarioCheckboxes = SCENARIOS.map(function (s, i) {
return (
'<label class="checkbox-row" for="np-scenario-' + i + '">' +
'<input type="checkbox" id="np-scenario-' + i + '" data-np-scenario="' + escapeAttr(s.id) + '">' +
'<span>' + escapeHtml(s.name) + '</span>' +
'</label>'
);
}).join('');
const targetTypeOpts = TARGET_TYPES.map(function (t) { return '<option value="' + escapeAttr(t) + '">' + escapeHtml(t) + '</option>'; }).join('');
return (
'<div class="modal" role="dialog" aria-labelledby="np-title">' +
'<div class="modal__head">' +
'<h2 id="np-title" class="modal__title">Nytt prosjekt</h2>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<form data-np-form autocomplete="off" onsubmit="return false;" style="display:flex; flex-direction:column; gap: var(--space-3);">' +
'<div class="field-row">' +
'<label class="field-label" for="np-name">Prosjektnavn<span class="required-mark">*</span></label>' +
'<input class="input" id="np-name" type="text" required>' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label" for="np-target-type">Target-type</label>' +
'<select class="select" id="np-target-type">' + targetTypeOpts + '</select>' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label" for="np-target-path">Target (path eller URL)</label>' +
'<input class="input" id="np-target-path" type="text" placeholder="~/repos/min-app eller https://github.com/org/repo">' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label" for="np-description">Beskrivelse (valgfri)</label>' +
'<textarea class="textarea" id="np-description" rows="2"></textarea>' +
'</div>' +
'<div class="field-row">' +
'<label class="field-label">Scenarioer (kryss av relevante)</label>' +
'<fieldset class="multi-select">' + scenarioCheckboxes + '</fieldset>' +
'</div>' +
'<div style="display:flex; gap: var(--space-2); margin-top: var(--space-2);">' +
'<button type="button" class="btn btn--primary" data-action="np-create">Opprett</button>' +
'<button type="button" class="btn btn--ghost" data-action="close-modal">Avbryt</button>' +
'</div>' +
'</form>' +
'</div>'
);
}
function renderDeleteProjectModal(project) {
return (
'<div class="modal" role="dialog" aria-labelledby="dp-title">' +
'<div class="modal__head">' +
'<h2 id="dp-title" class="modal__title">Slett prosjekt?</h2>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<p>Du sletter prosjektet <strong>' + escapeHtml(project.name) + '</strong>. Alle rapporter i prosjektet går tapt. Operasjonen kan ikke angres.</p>' +
'<div style="display:flex; gap: var(--space-2);">' +
'<button type="button" class="btn btn--primary" data-action="dp-confirm" data-project-id="' + escapeAttr(project.id) + '" style="background: var(--color-severity-critical); border-color: var(--color-severity-critical);">Ja, slett</button>' +
'<button type="button" class="btn btn--ghost" data-action="close-modal">Avbryt</button>' +
'</div>' +
'</div>'
);
}
function renderCatalogFormModal(cmd) {
const formHtml = renderCommandForm(cmd.id, { scope: 'cat' });
return (
'<div class="modal" role="dialog" aria-labelledby="cf-title">' +
'<div class="modal__head">' +
'<h2 id="cf-title" class="modal__title">' + escapeHtml(cmd.label) + '</h2>' +
'<button type="button" class="modal__close" data-action="close-modal" aria-label="Lukk">×</button>' +
'</div>' +
'<p style="color: var(--color-text-secondary); margin: 0;">' + escapeHtml(cmd.description) + '</p>' +
formHtml +
'</div>'
);
}
function attachActionHandlers() {
document.addEventListener('click', function (ev) {
const target = ev.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const cmdId = target.dataset.command;
// Navigation
if (action === 'goto-home') return navigate('home');
if (action === 'goto-catalog') return navigate('catalog');
if (action === 'goto-onboarding') {
onboardingActiveStep = ONBOARDING_GROUPS[0].id;
return navigate('onboarding');
}
// Theme toggle
if (action === 'toggle-theme') {
const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const next = cur === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
document.documentElement.style.colorScheme = next;
try { localStorage.setItem('llm-security-theme', next); } catch (e) {}
if (store && store.state && store.state.preferences) store.state.preferences.theme = next;
scheduleRender();
return;
}
// Export / import
if (action === 'export-state') return exportState();
if (action === 'import-state') {
const inp = document.querySelector('[data-import-input]');
if (inp) inp.click();
return;
}
// Demo data
if (action === 'load-demo') {
loadDemoState();
return;
}
// Onboarding
if (action === 'onboarding-step') {
onboardingActiveStep = target.dataset.step;
scheduleRender();
return;
}
if (action === 'onboarding-next') {
const idx = ONBOARDING_GROUPS.findIndex(function (g) { return g.id === onboardingActiveStep; });
if (idx < ONBOARDING_GROUPS.length - 1) {
onboardingActiveStep = ONBOARDING_GROUPS[idx + 1].id;
scheduleRender();
}
return;
}
if (action === 'onboarding-prev') {
const idx = ONBOARDING_GROUPS.findIndex(function (g) { return g.id === onboardingActiveStep; });
if (idx > 0) {
onboardingActiveStep = ONBOARDING_GROUPS[idx - 1].id;
scheduleRender();
}
return;
}
if (action === 'onboarding-finish') {
if (!store.state.activeSurface || store.state.activeSurface === 'onboarding') {
navigate('home');
}
return;
}
// Project tabs
if (action === 'project-screen') {
currentProjectScreen = target.dataset.screen;
scheduleRender();
return;
}
if (action === 'project-tab') {
currentProjectTab = target.dataset.tab;
scheduleRender();
return;
}
// Project lifecycle
if (action === 'open-project') {
const pid = target.dataset.projectId;
store.state.activeProjectId = pid;
currentProjectScreen = 'rapporter';
currentProjectTab = 'discover';
navigate('project');
return;
}
if (action === 'new-project') {
openModal(renderNewProjectModal());
return;
}
if (action === 'delete-project') {
const pid = target.dataset.projectId;
const p = findProject(pid);
if (p) openModal(renderDeleteProjectModal(p));
return;
}
if (action === 'dp-confirm') {
const pid = target.dataset.projectId;
const list = store.state.projects;
for (let i = 0; i < list.length; i++) {
if (list[i].id === pid) { list.splice(i, 1); break; }
}
if (store.state.activeProjectId === pid) store.state.activeProjectId = null;
closeModal();
navigate('home');
return;
}
if (action === 'np-create') {
const modal = target.closest('.modal');
const name = modal.querySelector('#np-name').value.trim();
if (!name) { alert('Prosjektnavn er påkrevd.'); return; }
const targetType = modal.querySelector('#np-target-type').value;
const targetPath = modal.querySelector('#np-target-path').value.trim();
const description = modal.querySelector('#np-description').value.trim();
const scenarios = Array.from(modal.querySelectorAll('[data-np-scenario]')).filter(function (el) { return el.checked; }).map(function (el) { return el.dataset.npScenario; });
const project = {
id: uuid(),
name: name,
description: description,
target_type: targetType,
target_path: targetPath,
scenarios: scenarios,
createdAt: new Date().toISOString(),
reports: {}
};
store.state.projects.push(project);
store.state.activeProjectId = project.id;
currentProjectScreen = 'rapporter';
currentProjectTab = 'discover';
closeModal();
navigate('project');
return;
}
// Modal close
if (action === 'close-modal') {
closeModal();
return;
}
// Catalog
if (action === 'catalog-toggle-group') {
const grp = target.dataset.group;
const exp = target.closest('.expansion');
if (exp) {
const open = exp.getAttribute('aria-expanded') === 'true';
exp.setAttribute('aria-expanded', open ? 'false' : 'true');
}
return;
}
if (action === 'catalog-open-form') {
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
if (cmd) openModal(renderCatalogFormModal(cmd));
return;
}
// Command form actions
if (action === 'preview-command') {
const formEl = ev.target.closest('form.command-form');
if (!formEl) return;
const data = readCommandFormValues(formEl);
const cid = formEl.dataset.commandForm;
const str = buildCommand(cid, data);
showCommandPreview(formEl, str);
// Lagre input på prosjekt-skjemaer (scope=p)
if (formEl.dataset.commandFormScope === 'p') {
const project = findProject(store.state.activeProjectId);
if (project) {
if (!project.reports) project.reports = {};
project.reports[cid] = project.reports[cid] || {};
project.reports[cid].input = data;
}
}
return;
}
if (action === 'copy-command') {
const formEl = ev.target.closest('form.command-form');
if (!formEl) return;
const data = readCommandFormValues(formEl);
const cid = formEl.dataset.commandForm;
const str = buildCommand(cid, data);
copyCommandToClipboard(str).then(function (ok) {
flashCopyConfirm(formEl, ok ? 'Kopiert til utklippstavle.' : 'Kopiering feilet — bruk forhåndsvisning.');
showCommandPreview(formEl, str);
});
if (formEl.dataset.commandFormScope === 'p') {
const project = findProject(store.state.activeProjectId);
if (project) {
if (!project.reports) project.reports = {};
project.reports[cid] = project.reports[cid] || {};
project.reports[cid].input = data;
}
}
return;
}
// Paste-import
if (action === 'parse-paste') {
const row = target.closest('[data-paste-import]');
if (!row) return;
const ta = row.querySelector('[data-paste-text]');
if (!ta || !ta.value.trim()) return;
handlePasteImport(cmdId, ta.value);
return;
}
if (action === 'clear-report') {
const project = findProject(store.state.activeProjectId);
if (project && project.reports && project.reports[cmdId]) {
delete project.reports[cmdId];
const slot = document.querySelector('[data-report-slot="' + CSS.escape(cmdId) + '"]');
if (slot) slot.innerHTML = '';
scheduleRender();
}
return;
}
});
// Modal backdrop click closes
document.addEventListener('click', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-modal-backdrop]')) {
closeModal();
}
});
// ESC closes modal
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') closeModal();
});
// Catalog search
document.addEventListener('input', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-catalog-search]')) {
catalogSearchQuery = ev.target.value;
const groupsEl = document.querySelector('[data-catalog-groups]');
if (groupsEl) groupsEl.innerHTML = renderCatalogGroupsHtml();
return;
}
// Onboarding fields persist on input (debounced via throttledWriter)
if (ev.target && ev.target.matches && ev.target.matches('[data-onboarding-field]')) {
const path = ev.target.dataset.cfField;
const t = ev.target.dataset.cfType;
let val;
if (t === 'multiSelect') {
const form = ev.target.closest('form');
val = Array.from(form.querySelectorAll('[data-cf-field="' + CSS.escape(path) + '"]')).filter(function (el) { return el.checked; }).map(function (el) { return el.dataset.cfMulti; });
} else if (t === 'boolean') {
val = ev.target.checked;
} else if (t === 'number') {
val = ev.target.value === '' ? null : Number(ev.target.value);
} else {
val = ev.target.value;
}
setOnboardingValue(path, val);
}
});
// Onboarding change for select/checkbox (input-event covers most, but
// some browsers fire 'change' instead for select)
document.addEventListener('change', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-onboarding-field]')) {
// Trigger same handling as input
const evt = new Event('input', { bubbles: true });
ev.target.dispatchEvent(evt);
// Re-render progress sidebar (cheap)
if (store.state.activeSurface === 'onboarding') {
scheduleRender();
}
}
});
// Import file picker
document.addEventListener('change', function (ev) {
if (ev.target && ev.target.matches && ev.target.matches('[data-import-input]')) {
const f = ev.target.files && ev.target.files[0];
if (!f) return;
importState(f).catch(function (err) {
alert('Import feilet: ' + err.message);
});
ev.target.value = ''; // reset input så samme fil kan velges igjen
}
});
}
// ============================================================
// ENTRY POINT
// ============================================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
bootstrap().then(attachActionHandlers).catch(function (err) {
console.error('[llm-security playground] bootstrap failed:', err);
document.body.innerHTML = '<div class="app-shell" style="padding: var(--space-8);"><h1>Bootstrap-feil</h1><pre>' + escapeHtml(String(err)) + '</pre></div>';
});
});
} else {
bootstrap().then(attachActionHandlers).catch(function (err) {
console.error('[llm-security playground] bootstrap failed:', err);
document.body.innerHTML = '<div class="app-shell" style="padding: var(--space-8);"><h1>Bootstrap-feil</h1><pre>' + escapeHtml(String(err)) + '</pre></div>';
});
}
})();
</script>
</body>
</html>