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>
2737 lines
137 KiB
HTML
2737 lines
137 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="nb" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
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>
|