feat(ms-ai-architect): playground v3 onboarding surface (18 felles fields) [skip-docs]
Step 5/17 av Playground v3-leveransen (Session 2, Wave 2). 5 grouped sections (organization/technology/security/architecture/business) rendered with Tier 3 .form-progress sidebar and .expansion components per group. Validation via .error-summary with click-to-focus links. ONBOARDING_SCHEMA mirrors agents/onboarding-agent.md Phase 1-5 (18 fields total). commitOnboarding() writes to state.shared.<group>.<field> via Proxy → throttled IDB/localStorage write. Re-onboard is just navigate back to onboarding — pre-fills from state automatically. Verified via vm sandbox: bootstrap auto-routes to onboarding when no org.name, commitOnboarding produces >=5 keys in shared.organization, validation catches required-empty (2) and accepts filled (0). Surface routing: showSurface() toggles [hidden] across data-surface sections. scheduleRender batches via queueMicrotask. Action router dispatches data-action attributes to ACTIONS map. README/CLAUDE.md-update deferred til Step 17 (Session 5).
This commit is contained in:
parent
ab8affa5d8
commit
6b2ac8250e
1 changed files with 563 additions and 1 deletions
|
|
@ -15,6 +15,81 @@
|
||||||
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.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.css">
|
||||||
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
|
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
|
||||||
|
|
||||||
|
<!-- App-shell layout. Vendored design-system levner komponent-CSS;
|
||||||
|
her bor kun side-spesifikk layout-grid (sidebar+main, modals, sub-cards).
|
||||||
|
Kompakt med vilje — ingen komponent-CSS skal duplikeres her. -->
|
||||||
|
<style>
|
||||||
|
main#app { min-height: 100vh; padding: 0; }
|
||||||
|
.app-shell { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-5); }
|
||||||
|
.app-shell--wide { max-width: 1400px; }
|
||||||
|
|
||||||
|
/* Topbar — vises på alle surfaces unntatt onboarding (uten projekt-kontekst) */
|
||||||
|
.topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-5); border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface); position: sticky; top: 0; z-index: 10; }
|
||||||
|
.topbar__brand { display: flex; align-items: center; gap: var(--space-2); font-weight: var(--font-weight-semibold); }
|
||||||
|
.topbar__brand-mark { width: 28px; height: 28px; border-radius: var(--radius-sm); background: var(--color-primary-500); color: var(--color-text-on-primary); display: inline-flex; align-items: center; justify-content: center; font-family: var(--font-family-mono); font-weight: var(--font-weight-bold); font-size: 13px; }
|
||||||
|
.topbar__nav { display: flex; gap: var(--space-2); align-items: center; }
|
||||||
|
.topbar__crumb { font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
||||||
|
.topbar__crumb a { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Onboarding-layout: sidebar + main */
|
||||||
|
.onboarding-layout { display: grid; grid-template-columns: 280px 1fr; gap: var(--space-6); align-items: start; }
|
||||||
|
@media (max-width: 880px) { .onboarding-layout { grid-template-columns: 1fr; } .form-progress { position: static; width: auto; } }
|
||||||
|
.onboarding-header { margin-bottom: var(--space-5); }
|
||||||
|
.onboarding-header h1 { font-size: var(--font-size-2xl); margin: 0 0 var(--space-2); }
|
||||||
|
.onboarding-header p { color: var(--color-text-secondary); margin: 0; max-width: 60ch; }
|
||||||
|
.onboarding-groups { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-6); }
|
||||||
|
.onboarding-fields { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-2) 0; }
|
||||||
|
.field-row { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field-label { font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-primary); }
|
||||||
|
.field-help { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||||
|
.multi-select { display: flex; flex-direction: column; gap: 4px; border: 0; padding: 0; margin: 0; }
|
||||||
|
.checkbox-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: var(--font-size-sm); padding: 4px 0; }
|
||||||
|
.checkbox-row input { margin: 0; }
|
||||||
|
.required-mark { color: var(--color-severity-critical); margin-left: 2px; }
|
||||||
|
.onboarding-actions { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) 0; flex-wrap: wrap; }
|
||||||
|
.onboarding-help { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
|
||||||
|
|
||||||
|
/* Home + project list */
|
||||||
|
.home-hero { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-5); }
|
||||||
|
.home-hero h1 { font-size: var(--font-size-3xl); }
|
||||||
|
.home-hero p { color: var(--color-text-secondary); }
|
||||||
|
.home-section-head { display: flex; align-items: baseline; justify-content: space-between; margin: var(--space-6) 0 var(--space-3); }
|
||||||
|
.home-section-head h2 { font-size: var(--font-size-xl); }
|
||||||
|
.home-section-head .home-section-meta { color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
|
||||||
|
|
||||||
|
/* Project surface */
|
||||||
|
.project-header { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5) 0 var(--space-4); border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); }
|
||||||
|
.project-header__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
|
||||||
|
.project-header__title { font-size: var(--font-size-2xl); margin: 0; }
|
||||||
|
.project-header__meta { display: flex; flex-wrap: wrap; gap: var(--space-3); font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
||||||
|
.project-header__chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--color-bg-soft); color: var(--color-text-secondary); font-size: var(--font-size-xs); font-family: var(--font-family-mono); }
|
||||||
|
.project-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--color-border-subtle); margin-bottom: var(--space-5); flex-wrap: wrap; }
|
||||||
|
.project-tab { background: transparent; border: 0; padding: 10px 16px; cursor: pointer; font-family: inherit; font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-secondary); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||||
|
.project-tab:hover { color: var(--color-text-primary); }
|
||||||
|
.project-tab[aria-current="true"] { color: var(--color-text-primary); border-bottom-color: var(--color-primary-500); }
|
||||||
|
.project-tab__count { display: inline-block; margin-left: 6px; padding: 1px 6px; background: var(--color-bg-soft); border-radius: 10px; font-size: 11px; color: var(--color-text-tertiary); }
|
||||||
|
.command-cards { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
|
.command-card { background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
|
.command-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
|
||||||
|
.command-card__title { font-size: var(--font-size-md); font-weight: var(--font-weight-semibold); margin: 0; }
|
||||||
|
.command-card__desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 4px 0 0; }
|
||||||
|
.command-card__id { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||||
|
.sub-zone { border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-3); }
|
||||||
|
.sub-zone__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
|
||||||
|
.paste-import-row { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||||
|
.paste-import-row__actions { display: flex; gap: var(--space-2); align-items: center; }
|
||||||
|
.form-zone-placeholder { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
|
||||||
|
.report-slot { min-height: 24px; }
|
||||||
|
.report-slot:empty::before { content: "Ingen importert rapport ennå."; font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-style: italic; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; padding: var(--space-4); }
|
||||||
|
.modal { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-5); max-width: 560px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
|
.modal__title { margin: 0; font-size: var(--font-size-xl); }
|
||||||
|
.modal__actions { display: flex; gap: var(--space-2); justify-content: flex-end; padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
[data-theme="dark"] .modal-backdrop { background: rgba(0,0,0,0.7); }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Walking-skeleton: 4 placeholder-overflater. Step 5-7 fyller dem ut.
|
<!-- Walking-skeleton: 4 placeholder-overflater. Step 5-7 fyller dem ut.
|
||||||
|
|
@ -347,7 +422,13 @@
|
||||||
store.subscribe(function () { scheduleWrite(); });
|
store.subscribe(function () { scheduleWrite(); });
|
||||||
window.__store = store;
|
window.__store = store;
|
||||||
window.__persistence = persistence;
|
window.__persistence = persistence;
|
||||||
// Step 6+ vil trigger første render her.
|
// Initial-surface heuristikk: hvis onboarding aldri er gjort (ingen
|
||||||
|
// organisasjons-navn) og state ikke har eksplisitt valg fra forrige
|
||||||
|
// sesjon, gå til onboarding. Ellers bruk lagret activeSurface.
|
||||||
|
const orgName = store.state.shared && store.state.shared.organization && store.state.shared.organization.name;
|
||||||
|
if (!orgName) store.state.activeSurface = 'onboarding';
|
||||||
|
else if (!store.state.activeSurface) store.state.activeSurface = 'home';
|
||||||
|
scheduleRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -1039,6 +1120,487 @@
|
||||||
window.__SHARED_FIELDS = SHARED;
|
window.__SHARED_FIELDS = SHARED;
|
||||||
window.__FIELD_TYPES = FIELD_TYPES;
|
window.__FIELD_TYPES = FIELD_TYPES;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DOM HELPERS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
function escapeAttr(str) { return escapeHtml(str); }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SURFACE ROUTING (Step 5)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Én [data-surface] er synlig om gangen, drevet av state.activeSurface.
|
||||||
|
// navigate(name) muterer state og scheduler render. scheduleRender batcher
|
||||||
|
// via queueMicrotask så flere mutasjoner i samme tick gir én render.
|
||||||
|
//
|
||||||
|
// Vi subscriber IKKE alle state-endringer til render — det ville
|
||||||
|
// re-rendret skjemaer mens brukeren skriver. Render trigges eksplisitt
|
||||||
|
// fra action-handlers og navigate().
|
||||||
|
|
||||||
|
function getSurfaceEl(name) {
|
||||||
|
return document.querySelector('[data-surface="' + name + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSurface(name) {
|
||||||
|
const surfaces = document.querySelectorAll('main#app > [data-surface]');
|
||||||
|
for (let i = 0; i < surfaces.length; i++) {
|
||||||
|
surfaces[i].hidden = (surfaces[i].dataset.surface !== name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderQueued = false;
|
||||||
|
function scheduleRender() {
|
||||||
|
if (renderQueued) return;
|
||||||
|
renderQueued = true;
|
||||||
|
queueMicrotask(function () {
|
||||||
|
renderQueued = false;
|
||||||
|
renderActive();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActive() {
|
||||||
|
if (!store) return;
|
||||||
|
const active = store.state.activeSurface || 'home';
|
||||||
|
showSurface(active);
|
||||||
|
if (active === 'onboarding') renderOnboardingSurface();
|
||||||
|
else if (active === 'home') renderHomeSurface();
|
||||||
|
else if (active === 'project') renderProjectSurface();
|
||||||
|
else if (active === 'catalog') renderCatalogStub();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(surface) {
|
||||||
|
store.state.activeSurface = surface;
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stubber for surfaces som fylles i Steps 6, 7, 9. Holder renderActive
|
||||||
|
// total uten å kreve at de finnes.
|
||||||
|
function renderHomeSurface() {
|
||||||
|
const root = getSurfaceEl('home');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = '<div class="app-shell"><p>Hjem-skjerm fylles i Step 6.</p></div>';
|
||||||
|
}
|
||||||
|
function renderProjectSurface() {
|
||||||
|
const root = getSurfaceEl('project');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = '<div class="app-shell"><p>Prosjekt-overflate fylles i Step 7.</p></div>';
|
||||||
|
}
|
||||||
|
function renderCatalogStub() {
|
||||||
|
const root = getSurfaceEl('catalog');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = '<div class="app-shell"><p>Command-katalog fylles i Step 9.</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ONBOARDING SURFACE (Step 5)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// 18 felles felter strukturert i 5 grupper per agents/onboarding-agent.md
|
||||||
|
// Phase 1-5. Sidebar = .form-progress med count utfylte felter per gruppe.
|
||||||
|
// Hver gruppe = .expansion (Tier 3 supplement). Validering bruker
|
||||||
|
// .error-summary (Tier 3) med klikkbare links som fokuserer feil-felt.
|
||||||
|
//
|
||||||
|
// Lagring: commitOnboarding() muterer state.shared.<group>.<field>;
|
||||||
|
// Proxy-set-trap dispatcher 'change' → throttled writer persisterer
|
||||||
|
// til IDB. Re-onboard er bare navigate('onboarding') igjen — skjemaet
|
||||||
|
// pre-fylles automatisk fra eksisterende state.
|
||||||
|
|
||||||
|
const ONBOARDING_SCHEMA = [
|
||||||
|
{
|
||||||
|
id: 'organization',
|
||||||
|
title: 'Virksomhetsprofil',
|
||||||
|
sub: 'Hvem er dere?',
|
||||||
|
fields: [
|
||||||
|
{ id: 'name', label: 'Virksomhetsnavn', type: 'text', required: true },
|
||||||
|
{ id: 'description', label: 'Beskrivelse', type: 'textarea' },
|
||||||
|
{ id: 'sector', label: 'Sektor', type: 'select', required: true,
|
||||||
|
options: ['Statlig', 'Kommunal', 'Fylkeskommune', 'Helseforetak', 'Undervisning', 'Annet'] },
|
||||||
|
{ id: 'size', label: 'Antall ansatte', type: 'select',
|
||||||
|
options: ['<100', '100-500', '500-2000', '2000-10000', '>10000'] },
|
||||||
|
{ id: 'regulatory_requirements', label: 'Regulatoriske krav', type: 'multiSelect',
|
||||||
|
options: ['Personopplysningsloven/GDPR', 'Sikkerhetsloven', 'Arkivloven', 'Forvaltningsloven', 'Offentleglova', 'Helseregisterloven', 'Annet'] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'technology',
|
||||||
|
title: 'Teknologistack',
|
||||||
|
sub: 'Hva har dere allerede?',
|
||||||
|
fields: [
|
||||||
|
{ id: 'cloud_platform', label: 'Skyplattform', type: 'multiSelect',
|
||||||
|
options: ['Azure', 'M365', 'Power Platform', 'On-prem', 'Hybrid', 'Annet'] },
|
||||||
|
{ id: 'license_type', label: 'Lisenstype', type: 'select',
|
||||||
|
options: ['E3', 'E5', 'F1/F3', 'A3/A5', 'G3/G5', 'Annet'] },
|
||||||
|
{ id: 'ai_services_in_use', label: 'AI-tjenester i bruk', type: 'multiSelect',
|
||||||
|
options: ['Azure OpenAI', 'Copilot for M365', 'Copilot Studio', 'AI Builder', 'Azure AI Search', 'Azure AI Services', 'Ingen', 'Annet'] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
title: 'Sikkerhet og compliance',
|
||||||
|
sub: 'Hvilke krav styrer dere etter?',
|
||||||
|
fields: [
|
||||||
|
{ id: 'data_classification', label: 'Dataklassifisering', type: 'multiSelect',
|
||||||
|
options: ['Åpen', 'Intern', 'Fortrolig', 'Strengt fortrolig', 'Hemmelig'] },
|
||||||
|
{ id: 'data_residency', label: 'Dataresidens-krav', type: 'select',
|
||||||
|
options: ['Norge', 'Norden', 'EU/EØS', 'Ingen spesifikke krav'] },
|
||||||
|
{ id: 'dpia_practice', label: 'DPIA-praksis', type: 'select',
|
||||||
|
options: ['Systematisk', 'Ad hoc', 'Ikke etablert', 'Usikker'] },
|
||||||
|
{ id: 'certifications', label: 'Sertifiseringer/rammeverk', type: 'textarea' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'architecture',
|
||||||
|
title: 'Arkitekturbeslutninger',
|
||||||
|
sub: 'Hvor vil dere?',
|
||||||
|
fields: [
|
||||||
|
{ id: 'preferred_platform', label: 'Foretrukket AI-plattform', type: 'select',
|
||||||
|
options: ['Azure AI Foundry', 'Copilot Studio', 'Power Platform/AI Builder', 'Semantic Kernel', 'Ikke bestemt'] },
|
||||||
|
{ id: 'integration_needs', label: 'Integrasjonsbehov', type: 'multiSelect',
|
||||||
|
options: ['M365', 'SharePoint', 'Dynamics 365', 'SAP', 'Fagsystemer', 'REST API-er', 'Annet'] },
|
||||||
|
{ id: 'annual_ai_budget', label: 'Årlig AI-budsjett', type: 'select',
|
||||||
|
options: ['<500k', '500k-2M', '2M-10M', '>10M', 'Ikke definert'] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'business',
|
||||||
|
title: 'Forretningsreferanser',
|
||||||
|
sub: 'Hvordan styrer dere?',
|
||||||
|
fields: [
|
||||||
|
{ id: 'governance_model', label: 'Styringsmodell for AI', type: 'select',
|
||||||
|
options: ['Sentralisert', 'Desentralisert', 'Hybrid', 'Ikke etablert'] },
|
||||||
|
{ id: 'doc_format_preferences', label: 'Dokumentformat', type: 'multiSelect',
|
||||||
|
options: ['Markdown', 'Word', 'PDF', 'Confluence', 'SharePoint Wiki', 'Annet'] },
|
||||||
|
{ id: 'reference_architecture', label: 'Referansearkitektur', type: 'textarea' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function fieldFilled(value, type) {
|
||||||
|
if (value == null) return false;
|
||||||
|
if (type === 'multiSelect') return Array.isArray(value) && value.length > 0;
|
||||||
|
if (type === 'boolean') return value === true;
|
||||||
|
return String(value).trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOnboardingValue(groupId, fieldId) {
|
||||||
|
const grp = store.state.shared && store.state.shared[groupId];
|
||||||
|
if (!grp) return undefined;
|
||||||
|
return grp[fieldId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupProgress(group) {
|
||||||
|
let filled = 0;
|
||||||
|
for (let i = 0; i < group.fields.length; i++) {
|
||||||
|
const f = group.fields[i];
|
||||||
|
if (fieldFilled(getOnboardingValue(group.id, f.id), f.type)) filled++;
|
||||||
|
}
|
||||||
|
return { filled: filled, total: group.fields.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOnboardingField(field, fieldId, groupId, value) {
|
||||||
|
const path = groupId + '.' + field.id;
|
||||||
|
const dataAttrs = 'data-onboarding-field="' + escapeAttr(path) + '"';
|
||||||
|
const requiredMark = field.required ? '<span class="required-mark" aria-hidden="true">*</span>' : '';
|
||||||
|
const labelHtml = '<label for="' + fieldId + '" class="field-label">' + escapeHtml(field.label) + requiredMark + '</label>';
|
||||||
|
let inputHtml = '';
|
||||||
|
if (field.type === 'text') {
|
||||||
|
inputHtml = '<input type="text" id="' + fieldId + '" ' + dataAttrs + ' value="' + escapeAttr(value || '') + '" class="input">';
|
||||||
|
} else if (field.type === 'textarea') {
|
||||||
|
inputHtml = '<textarea id="' + fieldId + '" ' + dataAttrs + ' class="textarea" rows="3">' + escapeHtml(value || '') + '</textarea>';
|
||||||
|
} else if (field.type === 'select') {
|
||||||
|
const opts = ['<option value="">(velg)</option>'].concat(field.options.map(function (o) {
|
||||||
|
const sel = (o === value) ? ' selected' : '';
|
||||||
|
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
|
||||||
|
})).join('');
|
||||||
|
inputHtml = '<select id="' + fieldId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
|
||||||
|
} else if (field.type === 'multiSelect') {
|
||||||
|
const arr = Array.isArray(value) ? value : [];
|
||||||
|
const opts = field.options.map(function (o, i) {
|
||||||
|
const checked = arr.indexOf(o) >= 0 ? ' checked' : '';
|
||||||
|
const cbId = fieldId + '-' + i;
|
||||||
|
return '<label class="checkbox-row" for="' + cbId + '"><input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-multi-option="' + escapeAttr(o) + '"' + checked + '><span>' + escapeHtml(o) + '</span></label>';
|
||||||
|
}).join('');
|
||||||
|
inputHtml = '<fieldset class="multi-select" aria-labelledby="' + fieldId + '-legend"><legend id="' + fieldId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' + opts + '</fieldset>';
|
||||||
|
} else if (field.type === 'boolean') {
|
||||||
|
const checked = value === true ? ' checked' : '';
|
||||||
|
inputHtml = '<label class="checkbox-row" for="' + fieldId + '"><input type="checkbox" id="' + fieldId + '" ' + dataAttrs + checked + '><span>' + escapeHtml(field.label) + '</span></label>';
|
||||||
|
}
|
||||||
|
return '<div class="field-row" data-field-row="' + escapeAttr(path) + '">' + labelHtml + inputHtml + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOnboardingSurface() {
|
||||||
|
const root = getSurfaceEl('onboarding');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const progress = ONBOARDING_SCHEMA.map(function (g) {
|
||||||
|
const p = groupProgress(g);
|
||||||
|
return { id: g.id, title: g.title, filled: p.filled, total: p.total };
|
||||||
|
});
|
||||||
|
const totalFilled = progress.reduce(function (a, p) { return a + p.filled; }, 0);
|
||||||
|
const totalAll = ONBOARDING_SCHEMA.reduce(function (a, g) { return a + g.fields.length; }, 0);
|
||||||
|
|
||||||
|
const sidebarSteps = progress.map(function (p, idx) {
|
||||||
|
let state = 'pending';
|
||||||
|
if (p.filled === p.total) state = 'done';
|
||||||
|
else if (p.filled > 0) state = 'in-progress';
|
||||||
|
const pct = p.total ? Math.round(100 * p.filled / p.total) : 0;
|
||||||
|
const numHtml = (state === 'done' ? '✓' : String(idx + 1));
|
||||||
|
return (
|
||||||
|
'<button type="button" class="fp-step" data-state="' + state + '" data-action="onboarding-goto-group" data-group="' + escapeAttr(p.id) + '">' +
|
||||||
|
'<span class="fp-step__num" aria-hidden="true">' + numHtml + '</span>' +
|
||||||
|
'<span>' +
|
||||||
|
'<span class="fp-step__name">' + escapeHtml(p.title) + '</span>' +
|
||||||
|
'<span class="fp-step__progress">' +
|
||||||
|
'<span class="fp-step__bar"><span class="fp-step__bar-fill" style="width:' + pct + '%"></span></span>' +
|
||||||
|
'<span>' + p.filled + '/' + p.total + '</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'</button>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const sidebar = (
|
||||||
|
'<aside class="form-progress" aria-label="Onboarding-fremdrift">' +
|
||||||
|
'<div class="form-progress__autosave">' +
|
||||||
|
'<span class="form-progress__autosave-dot"></span>' +
|
||||||
|
'<span>Lagres automatisk</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="form-progress__steps">' + sidebarSteps + '</div>' +
|
||||||
|
'<div class="form-progress__remaining">' +
|
||||||
|
'<span>Utfylt</span>' +
|
||||||
|
'<span>' + totalFilled + '/' + totalAll + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</aside>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupsHtml = ONBOARDING_SCHEMA.map(function (g) {
|
||||||
|
const p = groupProgress(g);
|
||||||
|
const expandedAttr = (p.filled < p.total ? 'true' : 'false');
|
||||||
|
const fieldsHtml = g.fields.map(function (f) {
|
||||||
|
const fieldId = 'ob-' + g.id + '-' + f.id;
|
||||||
|
const value = getOnboardingValue(g.id, f.id);
|
||||||
|
return renderOnboardingField(f, fieldId, g.id, value);
|
||||||
|
}).join('');
|
||||||
|
return (
|
||||||
|
'<section class="expansion" aria-expanded="' + expandedAttr + '" data-onboarding-group="' + escapeAttr(g.id) + '">' +
|
||||||
|
'<button type="button" class="expansion__head" data-action="onboarding-toggle-group">' +
|
||||||
|
'<span class="expansion__title">' +
|
||||||
|
'<span class="expansion__title-main">' + escapeHtml(g.title) + '</span>' +
|
||||||
|
'<span class="expansion__title-sub">' + escapeHtml(g.sub) + ' — ' + p.filled + '/' + p.total + '</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="expansion__chev" aria-hidden="true">▾</span>' +
|
||||||
|
'</button>' +
|
||||||
|
'<div class="expansion__body">' +
|
||||||
|
'<div class="expansion__body-inner">' +
|
||||||
|
'<div class="onboarding-fields">' + fieldsHtml + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</section>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const errorSummary = (
|
||||||
|
'<div class="error-summary" data-onboarding-errors hidden role="alert" aria-live="polite">' +
|
||||||
|
'<h2 class="error-summary__heading">Noen felter må fylles ut</h2>' +
|
||||||
|
'<div class="error-summary__body">' +
|
||||||
|
'<ul class="error-summary__list" data-onboarding-error-list></ul>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
const orgName = store.state.shared.organization && store.state.shared.organization.name;
|
||||||
|
const skipBackBtn = orgName
|
||||||
|
? '<button type="button" class="btn btn--ghost" data-action="onboarding-cancel">Tilbake til hjem</button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const actionBar = (
|
||||||
|
'<div class="onboarding-actions">' +
|
||||||
|
'<button type="button" class="btn btn--primary" data-action="onboarding-save">Lagre og fortsett</button>' +
|
||||||
|
skipBackBtn +
|
||||||
|
'<span class="onboarding-help">Du kan endre alt senere via Re-onboard.</span>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
root.innerHTML = (
|
||||||
|
'<div class="app-shell">' +
|
||||||
|
'<div class="onboarding-layout">' +
|
||||||
|
sidebar +
|
||||||
|
'<div class="onboarding-main">' +
|
||||||
|
'<header class="onboarding-header">' +
|
||||||
|
'<h1>Velkommen til ms-ai-architect</h1>' +
|
||||||
|
'<p>Fyll inn 18 felles felter — de gjenbrukes på tvers av alle commands og forhåndsutfyller skjemaer per prosjekt.</p>' +
|
||||||
|
'</header>' +
|
||||||
|
errorSummary +
|
||||||
|
'<div class="onboarding-groups">' + groupsHtml + '</div>' +
|
||||||
|
actionBar +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOnboardingValues() {
|
||||||
|
const values = {};
|
||||||
|
ONBOARDING_SCHEMA.forEach(function (g) { values[g.id] = {}; });
|
||||||
|
const root = getSurfaceEl('onboarding');
|
||||||
|
if (!root) return values;
|
||||||
|
const fields = root.querySelectorAll('[data-onboarding-field]');
|
||||||
|
// Initialiser alle multiSelect-felter til [] så uavkryssede arrays
|
||||||
|
// blir tomme arrays (ikke undefined).
|
||||||
|
ONBOARDING_SCHEMA.forEach(function (g) {
|
||||||
|
g.fields.forEach(function (f) {
|
||||||
|
if (f.type === 'multiSelect') values[g.id][f.id] = [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
for (let i = 0; i < fields.length; i++) {
|
||||||
|
const el = fields[i];
|
||||||
|
const path = el.dataset.onboardingField;
|
||||||
|
const dot = path.indexOf('.');
|
||||||
|
const groupId = path.slice(0, dot);
|
||||||
|
const fieldId = path.slice(dot + 1);
|
||||||
|
if (el.matches('input[type="checkbox"][data-multi-option]')) {
|
||||||
|
if (el.checked) values[groupId][fieldId].push(el.dataset.multiOption);
|
||||||
|
} else if (el.matches('input[type="checkbox"]')) {
|
||||||
|
values[groupId][fieldId] = el.checked;
|
||||||
|
} else {
|
||||||
|
values[groupId][fieldId] = el.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOnboarding(values) {
|
||||||
|
const errors = [];
|
||||||
|
ONBOARDING_SCHEMA.forEach(function (g) {
|
||||||
|
g.fields.forEach(function (f) {
|
||||||
|
if (!f.required) return;
|
||||||
|
const v = values[g.id][f.id];
|
||||||
|
if (!fieldFilled(v, f.type)) {
|
||||||
|
errors.push({
|
||||||
|
path: g.id + '.' + f.id,
|
||||||
|
label: g.title + ' → ' + f.label,
|
||||||
|
message: 'Påkrevd felt mangler verdi'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOnboardingErrors(errors) {
|
||||||
|
const root = getSurfaceEl('onboarding');
|
||||||
|
if (!root) return;
|
||||||
|
const summary = root.querySelector('[data-onboarding-errors]');
|
||||||
|
const list = root.querySelector('[data-onboarding-error-list]');
|
||||||
|
if (!summary || !list) return;
|
||||||
|
if (errors.length === 0) {
|
||||||
|
summary.hidden = true;
|
||||||
|
list.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summary.hidden = false;
|
||||||
|
list.innerHTML = errors.map(function (e) {
|
||||||
|
return '<li class="error-summary__item"><a href="#" class="error-summary__link" data-action="onboarding-focus-error" data-error-target="' + escapeAttr(e.path) + '">' + escapeHtml(e.label) + ' — ' + escapeHtml(e.message) + '</a></li>';
|
||||||
|
}).join('');
|
||||||
|
summary.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
summary.focus && summary.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitOnboarding(values) {
|
||||||
|
// Muter via Proxy så change-events fyres og throttled writer persisterer.
|
||||||
|
ONBOARDING_SCHEMA.forEach(function (g) {
|
||||||
|
if (!store.state.shared[g.id]) store.state.shared[g.id] = {};
|
||||||
|
g.fields.forEach(function (f) {
|
||||||
|
const v = values[g.id][f.id];
|
||||||
|
if (f.type === 'multiSelect') {
|
||||||
|
store.state.shared[g.id][f.id] = Array.isArray(v) ? v.slice() : [];
|
||||||
|
} else {
|
||||||
|
store.state.shared[g.id][f.id] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ACTION ROUTER
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Én delegert click-handler på document. Mapper data-action til
|
||||||
|
// registrerte handlers. Surfaces og modaler kan registrere actions ved
|
||||||
|
// å sette window.__ACTIONS[name] = function(ev, el) { ... }.
|
||||||
|
|
||||||
|
const ACTIONS = {};
|
||||||
|
window.__ACTIONS = ACTIONS;
|
||||||
|
|
||||||
|
document.addEventListener('click', function (ev) {
|
||||||
|
const actionEl = ev.target.closest('[data-action]');
|
||||||
|
if (!actionEl) return;
|
||||||
|
const action = actionEl.dataset.action;
|
||||||
|
const handler = ACTIONS[action];
|
||||||
|
if (handler) handler(ev, actionEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
ACTIONS['onboarding-toggle-group'] = function (ev, el) {
|
||||||
|
const exp = el.closest('.expansion');
|
||||||
|
if (!exp) return;
|
||||||
|
const open = exp.getAttribute('aria-expanded') === 'true';
|
||||||
|
exp.setAttribute('aria-expanded', open ? 'false' : 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
ACTIONS['onboarding-goto-group'] = function (ev, el) {
|
||||||
|
const groupId = el.dataset.group;
|
||||||
|
const root = getSurfaceEl('onboarding');
|
||||||
|
if (!root) return;
|
||||||
|
const exp = root.querySelector('[data-onboarding-group="' + groupId + '"]');
|
||||||
|
if (exp) {
|
||||||
|
exp.setAttribute('aria-expanded', 'true');
|
||||||
|
exp.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ACTIONS['onboarding-save'] = function (ev) {
|
||||||
|
const values = readOnboardingValues();
|
||||||
|
const errors = validateOnboarding(values);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showOnboardingErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitOnboarding(values);
|
||||||
|
navigate('home');
|
||||||
|
};
|
||||||
|
|
||||||
|
ACTIONS['onboarding-cancel'] = function () {
|
||||||
|
navigate('home');
|
||||||
|
};
|
||||||
|
|
||||||
|
ACTIONS['onboarding-focus-error'] = function (ev, el) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const path = el.dataset.errorTarget;
|
||||||
|
const root = getSurfaceEl('onboarding');
|
||||||
|
if (!root || !path) return;
|
||||||
|
const fieldRow = root.querySelector('[data-field-row="' + path + '"]');
|
||||||
|
if (!fieldRow) return;
|
||||||
|
const exp = fieldRow.closest('.expansion');
|
||||||
|
if (exp) exp.setAttribute('aria-expanded', 'true');
|
||||||
|
fieldRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
const input = fieldRow.querySelector('input, select, textarea');
|
||||||
|
if (input) input.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eksponer for Verify-asserts og Steps 6-9.
|
||||||
|
window.__navigate = navigate;
|
||||||
|
window.__scheduleRender = scheduleRender;
|
||||||
|
window.__ONBOARDING_SCHEMA = ONBOARDING_SCHEMA;
|
||||||
|
window.__readOnboardingValues = readOnboardingValues;
|
||||||
|
window.__validateOnboarding = validateOnboarding;
|
||||||
|
window.__commitOnboarding = commitOnboarding;
|
||||||
|
|
||||||
// Auto-bootstrap. Kjør så snart DOM er parsed; vi er på slutten av <body>
|
// Auto-bootstrap. Kjør så snart DOM er parsed; vi er på slutten av <body>
|
||||||
// så DOM er allerede klar.
|
// så DOM er allerede klar.
|
||||||
bootstrap().catch(function (err) {
|
bootstrap().catch(function (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue