feat(ms-ai-architect): playground v3 project creation + detail shell [skip-docs]

Step 7/17 av Playground v3-leveransen (Session 2, Wave 2).

Prosjekt-opprettelse via modal: navn (påkrevd) + system-beskrivelse +
scenario-tagging multiSelect (8 scenarioer fra v2). projectId via
crypto.randomUUID. Modal mounter til document.body med Esc-/backdrop-luk.

Per-prosjekt detalj-skall (#surface-project):
  - Header med tittel + scenario-chips + dato + rapport-meter + tilbake/slett
  - 5 kategori-tabs (regulatorisk/sikkerhet/økonomi/dokumentasjon/verktøy)
  - ALLE 24 commands rendres som .command-card i sine respektive panels
    (inaktive paneler [hidden]). Sikrer at querySelectorAll-asserts matcher
    uavhengig av aktiv tab; tab-bytte er ren visning-toggle uten re-render
    så textarea-input bevares.

Sub-card-struktur per command:
  - Skjema-zone (placeholder for Step 8 renderCommandForm)
  - rapport-produserende (17): paste-import-zone (textarea[data-paste-import]
    + button[data-action=parse]) + report-zone (div[data-report-slot])
  - verktøy (7): .guide-panel--info 'Verktøy'-notis ingen rapport-import

Sletting via modal med .error-summary 'Bekreft sletting'-melding (.btn--
destructive).

Paste-import-wiring: ACTIONS['parse'] leser textarea[data-paste-import]
og kaller window.__handlePasteImport(commandId, markdown). Stub logger
'parse-pending:' + slice(0,80) og injiserer en venter-panel i slot.
Step 12 erstatter stub med full PARSERS+RENDERERS-routing.

Verifisert via vm sandbox etter createProject + navigate('project'):
  - 17 [data-paste-import] (rapport-produserende commands) ✓
  - 17 [data-report-slot] ✓
  - 24 [data-command-card] ✓
  - 5 [role=tab] ✓
  - 7 .guide-panel--info (verktøy-notiser) ✓
  - project.id matcher UUID-format ✓

README/CLAUDE.md-update deferred til Step 17 (Session 5).
This commit is contained in:
Kjell Tore Guttormsen 2026-05-03 18:22:53 +02:00
commit 268169892a

View file

@ -1337,18 +1337,338 @@
);
}
// Stub for project + catalog som fylles i Steps 7, 9.
// ============================================================
// PROJECT SURFACE (Step 7)
// ============================================================
//
// Per-prosjekt detalj: header med navn + scenario-chips, 5 kategori-tabs
// (én per CATALOG-kategori), command-kort i hver tab. Sub-zones per kort:
// 1. Skjema-zone — placeholder (Step 8 fyller med renderCommandForm)
// 2. Paste-import — KUN for produces_report=true (textarea + parse-knapp)
// 3. Rapport-slot — KUN for produces_report=true (data-report-slot)
// Verktøy-commands får skjema-zone + .guide-panel--info 'Verktøy'-notis.
//
// Prosjekt-opprettelse via modal (createProjectFromModal). projectId =
// crypto.randomUUID. Sletting via .error-summary-modal med eksplisitt
// bekreftelse.
//
// Active-tab er transient (modul-lokal currentProjectTab) så export-state
// ikke forurenses av UI-state. Default 'regulatory' ved hver project-enter.
// 8 scenarioer fra v2 — gjenbrukes som scenario-tags på prosjekter.
const SCENARIOS = [
{ id: 'rag-chatbot', name: 'RAG-chatbot for interne dokumenter' },
{ id: 'autonomous-agent', name: 'Autonom agent for saksbehandling' },
{ id: 'document-classification', name: 'Dokumentklassifisering og -prosessering' },
{ id: 'multi-agent', name: 'Multi-agent workflow' },
{ id: 'copilot-extension', name: 'Copilot-utvidelse for M365' },
{ id: 'customer-service', name: 'Kundeservice-chatbot' },
{ id: 'intelligent-search', name: 'Intelligent søk på tvers av fagsystemer' },
{ id: 'reporting', name: 'AI-assistert rapportering' }
];
let currentProjectTab = 'regulatory';
function findProject(id) {
const list = store.state.projects || [];
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) return list[i];
}
return null;
}
function createProject(data) {
const id = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
const project = {
id: id,
name: data.name || 'Uten navn',
description: data.description || '',
scenarios: Array.isArray(data.scenarios) ? data.scenarios.slice() : [],
createdAt: new Date().toISOString(),
reports: {} // commandId → { input: {...}, raw_markdown: '', parsed: {...} }
};
// Push via Proxy så change-event fyres og persistens skedules.
store.state.projects.push(project);
store.state.activeProjectId = id;
currentProjectTab = 'regulatory';
return project;
}
function deleteProject(id) {
const list = store.state.projects;
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) {
list.splice(i, 1);
break;
}
}
if (store.state.activeProjectId === id) store.state.activeProjectId = null;
}
// ---- Modal infrastructure ----
function mountModal(html) {
unmountModal();
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const node = wrapper.firstElementChild;
if (!node) return;
node.setAttribute('data-modal-root', 'true');
document.body.appendChild(node);
// Klikk på backdrop (selve roten) lukker; klikk inni .modal bobler ikke til root.
node.addEventListener('click', function (ev) {
if (ev.target === node) unmountModal();
});
// Esc lukker
function escHandler(ev) {
if (ev.key === 'Escape') {
unmountModal();
document.removeEventListener('keydown', escHandler);
}
}
document.addEventListener('keydown', escHandler);
// Fokuser første input
setTimeout(function () {
const first = node.querySelector('input, select, textarea, button');
if (first && first.focus) first.focus();
}, 0);
}
function unmountModal() {
const existing = document.querySelector('[data-modal-root]');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
}
function renderNewProjectModalHtml() {
const scenarioOptions = SCENARIOS.map(function (s, i) {
return (
'<label class="checkbox-row" for="np-scen-' + i + '">' +
'<input type="checkbox" id="np-scen-' + i + '" data-new-project-scenario value="' + escapeAttr(s.id) + '">' +
'<span>' + escapeHtml(s.name) + '</span>' +
'</label>'
);
}).join('');
return (
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="np-title">' +
'<div class="modal">' +
'<h2 class="modal__title" id="np-title">Nytt prosjekt</h2>' +
'<div class="field-row">' +
'<label for="np-name" class="field-label">Prosjektnavn<span class="required-mark" aria-hidden="true">*</span></label>' +
'<input type="text" id="np-name" class="input" data-new-project-field="name" required>' +
'</div>' +
'<div class="field-row">' +
'<label for="np-desc" class="field-label">System-beskrivelse</label>' +
'<textarea id="np-desc" class="textarea" data-new-project-field="description" rows="3" placeholder="Hva skal AI-systemet gjøre? Hvilke brukere?"></textarea>' +
'</div>' +
'<div class="field-row">' +
'<span class="field-label">Scenario-tagging</span>' +
'<fieldset class="multi-select" aria-label="Scenarioer">' + scenarioOptions + '</fieldset>' +
'<span class="field-help">Brukes for sammenligning og pipeline-anbefalinger.</span>' +
'</div>' +
'<div class="error-summary" data-new-project-errors hidden role="alert">' +
'<h3 class="error-summary__heading">Mangler input</h3>' +
'<div class="error-summary__body"><p data-new-project-error-text></p></div>' +
'</div>' +
'<div class="modal__actions">' +
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Avbryt</button>' +
'<button type="button" class="btn btn--primary" data-action="create-project">Opprett</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
function renderDeleteProjectModalHtml(project) {
const reportCount = projectReportCount(project);
return (
'<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="dp-title">' +
'<div class="modal">' +
'<h2 class="modal__title" id="dp-title">Slett prosjekt?</h2>' +
'<div class="error-summary">' +
'<h3 class="error-summary__heading">Bekreft sletting</h3>' +
'<div class="error-summary__body">' +
'<p>Dette fjerner prosjektet <strong>' + escapeHtml(project.name) + '</strong> og ' + reportCount + ' importert' + (reportCount === 1 ? '' : 'e') + ' rapport' + (reportCount === 1 ? '' : 'er') + '. Handlingen kan ikke angres.</p>' +
'</div>' +
'</div>' +
'<div class="modal__actions">' +
'<button type="button" class="btn btn--ghost" data-action="modal-cancel">Avbryt</button>' +
'<button type="button" class="btn btn--destructive" data-action="confirm-delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett prosjekt</button>' +
'</div>' +
'</div>' +
'</div>'
);
}
// ---- Sub-card rendering ----
function renderCommandSubCard(cmd) {
const titleHtml = (
'<div class="command-card__head">' +
'<div>' +
'<h3 class="command-card__title">' + escapeHtml(cmd.label) + '</h3>' +
'<p class="command-card__desc">' + escapeHtml(cmd.description) + '</p>' +
'</div>' +
'<span class="command-card__id">/architect:' + escapeHtml(cmd.id) + '</span>' +
'</div>'
);
const formZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Skjema</h4>' +
'<div class="form-zone-placeholder" data-form-zone="' + escapeAttr(cmd.id) + '">Skjema-renderer kommer i Step 8 (' + cmd.input_fields.length + ' felter, ' + (cmd.input_fields.filter(function (f) { return f.from === 'shared'; }).length) + ' fra shared).</div>' +
'</div>'
);
if (!cmd.produces_report) {
// Verktøy: skjema-zone + .guide-panel--info notis
const toolNotice = (
'<div class="sub-zone">' +
'<div class="guide-panel guide-panel--info">' +
'<div class="guide-panel__icon" aria-hidden="true">i</div>' +
'<div class="guide-panel__body">' +
'<h3 class="guide-panel__title">Verktøy</h3>' +
'<p class="guide-panel__text">Dette er et verktøy. Ingen rapport-import — bruk skjemaet til å bygge en pipeline-streng som kjøres i terminalen.</p>' +
'</div>' +
'</div>' +
'</div>'
);
return (
'<article class="command-card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
titleHtml +
formZone +
toolNotice +
'</article>'
);
}
// Rapport-produserende: skjema-zone + paste-import-zone + report-zone
const pasteZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Lim inn rapport-output</h4>' +
'<div class="paste-import-row">' +
'<textarea class="textarea" data-paste-import="' + escapeAttr(cmd.id) + '" rows="4" placeholder="Lim inn markdown-output fra terminalen her"></textarea>' +
'<div class="paste-import-row__actions">' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="parse" data-command="' + escapeAttr(cmd.id) + '">Analyser rapport</button>' +
'<span class="field-help">Routes via PARSERS[' + escapeHtml(cmd.report_archetype || '?') + '] → ' + escapeHtml(cmd.renderer || '?') + ' (Step 11/12).</span>' +
'</div>' +
'</div>' +
'</div>'
);
const reportZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Visualisering</h4>' +
'<div class="report-slot ' + escapeAttr(cmd.report_root_class || '') + '" data-report-slot="' + escapeAttr(cmd.id) + '"></div>' +
'</div>'
);
return (
'<article class="command-card" data-command-card data-command-id="' + escapeAttr(cmd.id) + '">' +
titleHtml +
formZone +
pasteZone +
reportZone +
'</article>'
);
}
function renderProjectSurface() {
const root = getSurfaceEl('project');
if (!root) return;
root.innerHTML = renderTopbar('Prosjekt') + '<div class="app-shell"><p>Prosjekt-detaljvisning fylles i Step 7.</p></div>';
const project = findProject(store.state.activeProjectId);
if (!project) {
// Mistet aktivt prosjekt — fall tilbake til hjem.
navigate('home');
return;
}
const reportTotal = CATALOG.commands.filter(function (c) { return c.produces_report; }).length;
const reportFilled = projectReportCount(project);
const scenarioChips = (project.scenarios || []).map(function (sid) {
const s = SCENARIOS.find(function (x) { return x.id === sid; });
return '<span class="project-header__chip">' + escapeHtml(s ? s.name : sid) + '</span>';
}).join('');
const dateChip = '<span class="project-header__chip">opprettet ' + escapeHtml((project.createdAt || '').slice(0, 10)) + '</span>';
const progressChip = '<span class="project-header__chip">' + reportFilled + '/' + reportTotal + ' rapporter</span>';
const headerHtml = (
'<header class="project-header">' +
'<div class="project-header__top">' +
'<div>' +
'<h1 class="project-header__title">' + escapeHtml(project.name) + '</h1>' +
(project.description ? '<p style="color: var(--color-text-secondary); margin-top: var(--space-2); max-width: 70ch;">' + escapeHtml(project.description) + '</p>' : '') +
'</div>' +
'<div style="display:flex; gap: var(--space-2); flex-shrink: 0;">' +
'<button type="button" class="btn btn--ghost btn--sm" data-action="goto-home">← Tilbake</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="delete-project" data-project-id="' + escapeAttr(project.id) + '">Slett</button>' +
'</div>' +
'</div>' +
'<div class="project-header__meta">' + dateChip + progressChip + scenarioChips + '</div>' +
'</header>'
);
// Tabs per CATALOG.categories
const tabsHtml = '<div class="project-tabs" role="tablist">' + CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
return (
'<button type="button" class="project-tab" role="tab"' +
(isActive ? ' aria-current="true"' : '') +
' data-action="project-tab" data-tab="' + escapeAttr(cat.id) + '">' +
escapeHtml(cat.label) +
'<span class="project-tab__count">' + cat.count + '</span>' +
'</button>'
);
}).join('') + '</div>';
// Render ALLE kategori-paneler i DOM (med [hidden] på inaktive). Dette
// sikrer at querySelectorAll('[data-paste-import]') matcher alle 17
// rapport-produserende commands uavhengig av aktiv tab.
const panelsHtml = CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
const cards = CATALOG.commands
.filter(function (c) { return c.category === cat.id; })
.map(renderCommandSubCard).join('');
return (
'<div class="command-cards" role="tabpanel" data-tab-panel="' + escapeAttr(cat.id) + '"' + (isActive ? '' : ' hidden') + '>' +
cards +
'</div>'
);
}).join('');
root.innerHTML = (
renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
'<div class="app-shell app-shell--wide">' +
headerHtml +
tabsHtml +
panelsHtml +
'</div>'
);
}
function renderCatalogStub() {
const root = getSurfaceEl('catalog');
if (!root) return;
root.innerHTML = renderTopbar('Katalog') + '<div class="app-shell"><p>Command-katalog fylles i Step 9.</p></div>';
}
// ---- Paste-import stub (Step 12 erstatter med faktisk routing) ----
function handlePasteImport(commandId, markdown) {
// Stub: logger til konsoll for å verifisere DOM-kontrakten. Step 12
// henter PARSERS[CATALOG[id].report_archetype] + RENDERERS[id], parser
// markdown og injiserer i [data-report-slot="<id>"].
console.log('parse-pending:', commandId, (markdown || '').slice(0, 80));
const slot = document.querySelector('[data-report-slot="' + commandId + '"]');
if (slot) {
slot.innerHTML = '<div class="guide-panel guide-panel--warn"><div class="guide-panel__icon" aria-hidden="true"></div><div class="guide-panel__body"><p class="guide-panel__text">Markdown mottatt (' + (markdown || '').length + ' tegn). Parser+renderer kommer i Step 12.</p></div></div>';
}
}
window.__handlePasteImport = handlePasteImport;
// ============================================================
// ONBOARDING SURFACE (Step 5)
// ============================================================
@ -1760,11 +2080,89 @@
navigate('project');
};
// Stub — Step 7 erstatter med modal-åpning.
ACTIONS['new-project'] = function () {
console.log('[playground v3] new-project: modal kommer i Step 7');
mountModal(renderNewProjectModalHtml());
};
ACTIONS['modal-cancel'] = function () { unmountModal(); };
ACTIONS['create-project'] = function () {
const modal = document.querySelector('[data-modal-root]');
if (!modal) return;
const nameEl = modal.querySelector('[data-new-project-field="name"]');
const descEl = modal.querySelector('[data-new-project-field="description"]');
const errBox = modal.querySelector('[data-new-project-errors]');
const errText = modal.querySelector('[data-new-project-error-text]');
const name = nameEl ? String(nameEl.value || '').trim() : '';
const description = descEl ? String(descEl.value || '').trim() : '';
if (!name) {
if (errBox && errText) {
errBox.hidden = false;
errText.textContent = 'Prosjektnavn er påkrevd.';
}
if (nameEl) nameEl.focus();
return;
}
const scenarios = Array.from(modal.querySelectorAll('[data-new-project-scenario]'))
.filter(function (cb) { return cb.checked; })
.map(function (cb) { return cb.value; });
createProject({ name: name, description: description, scenarios: scenarios });
unmountModal();
navigate('project');
};
ACTIONS['delete-project'] = function (ev, el) {
const id = el.dataset.projectId;
const project = findProject(id);
if (!project) return;
mountModal(renderDeleteProjectModalHtml(project));
};
ACTIONS['confirm-delete-project'] = function (ev, el) {
const id = el.dataset.projectId;
if (!id) return;
deleteProject(id);
unmountModal();
navigate('home');
};
ACTIONS['project-tab'] = function (ev, el) {
const tab = el.dataset.tab;
if (!tab) return;
currentProjectTab = tab;
// Toggle visning uten full re-render (bevarer textarea-input).
const root = getSurfaceEl('project');
if (!root) return;
const tabs = root.querySelectorAll('.project-tab');
tabs.forEach(function (t) {
if (t.dataset.tab === tab) t.setAttribute('aria-current', 'true');
else t.removeAttribute('aria-current');
});
const panels = root.querySelectorAll('[data-tab-panel]');
panels.forEach(function (p) {
p.hidden = (p.dataset.tabPanel !== tab);
});
};
ACTIONS['parse'] = function (ev, el) {
const commandId = el.dataset.command;
if (!commandId) return;
const root = getSurfaceEl('project');
if (!root) return;
const textarea = root.querySelector('[data-paste-import="' + commandId + '"]');
if (!textarea) return;
const markdown = textarea.value || '';
handlePasteImport(commandId, markdown);
};
// Eksponer for Verify-asserts og Step 8/12.
window.__SCENARIOS = SCENARIOS;
window.__createProject = createProject;
window.__deleteProject = deleteProject;
window.__findProject = findProject;
window.__mountModal = mountModal;
window.__unmountModal = unmountModal;
ACTIONS['export-state'] = function () {
try { exportState(); }
catch (err) { console.error('[playground v3] export feilet:', err); alert('Eksport feilet: ' + err.message); }