feat(ms-ai-architect): surfaces adopt page-header + key-stats (4 surfaces)

Steg 8 i v1.10.0-loepet. Wrappe alle 4 surfaces (Onboarding, Home, Catalog,
Project) med renderPageShell({eyebrow, title, lede, verdict, keyStats}, body):

- Onboarding: eyebrow ONBOARDING, lede tilpasset for 20-felts onboarding
- Home: dynamisk "Hei, {orgName | venn}", keyStats {PROSJEKTER, AKTIVE RAPPORTER}
- Catalog: keyStats {KOMMANDOER 24, AGENTER 12, SKILLS 5}
- Project: title=project.name, lede=description, verdict via inferProjectVerdict
  (block > go-with-conditions > approved > n-a), keyStats {RAPPORTER, SIST OPPDATERT}

Project-surface utvidet med .screen-tabs (A4 Tier 3): Oversikt / Rapporter /
Kontekst / Eksport. Rapporter er primaer (eksisterende category-tabs+panels);
andre skjermer er stub i Sesjon 2 og fylles ut i Sesjon 3-6. Screen-tabs CSS
inline i playground-style-blokk per scope-regel (plugin standalone).

Per R8: ingen .page__meta chips. Action-buttons (Tilbake/Slett) flyttet under
page-shell-headeren (verdict-slot tar ikke arbitrary HTML).

Helpers lagt til:
- inferProjectVerdict(project) — aggregert verdict, tom reports -> n-a
- inferProjectLastUpdated(project) — siste report.updatedAt eller createdAt
- ACTIONS['project-screen'] — toggle screen-tabs uten full re-render

Verify: 4/4 surfaces kaller renderPageShell. Tester: 215 statiske, 240 playground,
7 migrations PASS.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-04 03:33:22 +02:00
commit 6f1631a32f

View file

@ -176,6 +176,15 @@
.key-stat[data-modifier="high"] .key-stat__value { color: var(--color-severity-high); }
.key-stat[data-modifier="medium"] .key-stat__value { color: var(--color-severity-medium); }
.key-stat[data-modifier="low"] .key-stat__value { color: var(--color-severity-low); }
/* Tier 3 A4 — Screen-tabs (segmented). Inline her per scope-regel
(plugin standalone). Kandidat for hoisting til shared/ i Sesjon 6. */
.screen-tabs { display: flex; gap: var(--space-1); padding: 4px; background: var(--color-bg-soft); border-radius: var(--radius-md); width: fit-content; margin: 0 0 var(--space-4) 0; }
.screen-tab { padding: var(--space-2) var(--space-3); font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); background: transparent; border: none; border-radius: var(--radius-sm); cursor: pointer; color: var(--color-text-secondary); font-family: inherit; }
.screen-tab:hover { color: var(--color-text-primary); }
.screen-tab[aria-current="true"] { background: var(--color-surface); color: var(--color-text-primary); box-shadow: var(--shadow-sm); }
.screen { display: none; }
.screen[data-active="true"] { display: block; }
</style>
</head>
<body>
@ -1565,6 +1574,45 @@
return count;
}
// Aggregert verdict for project-surface page-shell. Henter parsed.verdict
// fra alle reports og kollapser til én pille: block > go-with-conditions
// > approved > n-a. Tom reports{} -> 'n-a' per Sesjon 2-risk-flagg.
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'; // tom = "krever oppmerksomhet"
const pct = filled / total;
@ -1647,17 +1695,20 @@
})();
const orgName = (store.state.shared.organization && store.state.shared.organization.name) || '';
const heroHtml = (
'<section class="home-hero">' +
'<h1>' + (orgName ? 'Hei, ' + escapeHtml(orgName) : 'ms-ai-architect') + '</h1>' +
'<p>' + (orgName
? 'Velg hvor du vil starte. Felles state er aktiv og forhåndsutfyller skjemaer.'
: 'Single-file arkitektur-rådgivning for Microsoft AI-stakken. Start med onboarding for å aktivere felles state.'
) + '</p>' +
'</section>'
);
const projectsSection = (
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 arkitektur-rådgivning for Microsoft AI-stakken. Start med onboarding for å aktivere felles state.',
verdict: 'n-a',
keyStats: [
{ label: 'PROSJEKTER', value: projects.length },
{ label: 'AKTIVE RAPPORTER', value: activeReportCount }
]
},
tracksHtml +
'<section class="home-projects">' +
'<div class="home-section-head">' +
'<h2>Mine prosjekter</h2>' +
@ -1671,9 +1722,7 @@
root.innerHTML = (
renderTopbar('Hjem') +
'<div class="app-shell">' +
heroHtml +
tracksHtml +
projectsSection +
homeShell +
'</div>'
);
}
@ -1709,6 +1758,11 @@
];
let currentProjectTab = 'regulatory';
// Screen-tabs på project-surface (A4 Tier 3): Oversikt / Rapporter /
// Kontekst / Eksport. Default 'rapporter' = primær arbeidsflate (eksisterende
// category-tabs + panels). Andre skjermer er stub i Sesjon 2 og fylles ut
// i senere sesjoner.
let currentProjectScreen = 'rapporter';
function findProject(id) {
const list = store.state.projects || [];
@ -1734,6 +1788,7 @@
store.state.projects.push(project);
store.state.activeProjectId = id;
currentProjectTab = 'regulatory';
currentProjectScreen = 'rapporter';
return project;
}
@ -1931,30 +1986,36 @@
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>'
// Action-bar (Tilbake / Slett) flyttet under page-shell-headeren —
// page__header har dedikert verdict-slot som ikke tar arbitrary HTML.
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>'
);
// Tabs per CATALOG.categories
// Screen-tabs (A4 Tier 3): Oversikt / Rapporter / Kontekst / Eksport.
// Sesjon 2: Rapporter er primær; andre er stub-skjermer som fylles ut
// i Sesjon 3-6.
const SCREENS = [
{ id: 'oversikt', label: 'Oversikt' },
{ id: 'rapporter', label: 'Rapporter' },
{ id: 'kontekst', label: 'Kontekst' },
{ id: 'eksport', label: 'Eksport' }
];
const screenTabsHtml = '<nav class="screen-tabs" role="tablist" aria-label="Prosjekt-skjermer">' + SCREENS.map(function (s) {
const isActive = currentProjectScreen === s.id;
return (
'<button type="button" class="screen-tab" role="tab"' +
' aria-current="' + (isActive ? 'true' : 'false') + '"' +
' data-action="project-screen" data-screen="' + escapeAttr(s.id) + '">' +
escapeHtml(s.label) +
'</button>'
);
}).join('') + '</nav>';
// Tabs per CATALOG.categories — kun synlig under "Rapporter"-skjermen.
const tabsHtml = '<div class="project-tabs" role="tablist">' + CATALOG.categories.map(function (cat) {
const isActive = currentProjectTab === cat.id;
return (
@ -1982,12 +2043,79 @@
);
}).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="screen" data-active="' + (currentProjectScreen === 'oversikt' ? 'true' : 'false') + '" data-screen-id="oversikt">' +
'<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>' +
(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>Sesjon 3+: aggregerte verdict-pillen, recommended-next-actions og top-risks vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const rapporterHtml = (
'<div class="screen" data-active="' + (currentProjectScreen === 'rapporter' ? 'true' : 'false') + '" data-screen-id="rapporter">' +
tabsHtml +
panelsHtml +
'</div>'
);
const kontekstHtml = (
'<div class="screen" data-active="' + (currentProjectScreen === 'kontekst' ? 'true' : 'false') + '" data-screen-id="kontekst">' +
'<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>Sesjon 3+: snapshot av de 20 fellesfeltene og hva som er prefilled per command vises her.</em></p>' +
'</div>' +
'</div>' +
'</div>'
);
const eksportHtml = (
'<div class="screen" data-active="' + (currentProjectScreen === 'eksport' ? 'true' : 'false') + '" data-screen-id="eksport">' +
'<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 topbar for hele state. Per-prosjekt eksport (PDF/Markdown) kommer i Sesjon 6.</p>' +
'</div>' +
'</div>' +
'</div>'
);
const projectShell = renderPageShell({
eyebrow: 'PROSJEKT',
title: project.name,
lede: project.description || '',
verdict: inferProjectVerdict(project),
keyStats: [
{ label: 'RAPPORTER', value: reportFilled + '/' + reportTotal },
{ label: 'SIST OPPDATERT', value: inferProjectLastUpdated(project) }
]
},
actionBar +
screenTabsHtml +
oversiktHtml +
rapporterHtml +
kontekstHtml +
eksportHtml
);
root.innerHTML = (
renderTopbar('Prosjekt: ' + escapeHtml(project.name)) +
'<div class="app-shell app-shell--wide">' +
headerHtml +
tabsHtml +
panelsHtml +
projectShell +
'</div>'
);
}
@ -2092,18 +2220,27 @@
const q = (catalogSearchQuery || '').trim().toLowerCase();
const totalMatches = CATALOG.commands.filter(function (c) { return catalogMatches(c, q); }).length;
const countText = totalMatches + ' av ' + CATALOG.commands.length + ' treff' + (q ? ' for «' + escapeHtml(catalogSearchQuery) + '»' : '');
const catalogShell = renderPageShell({
eyebrow: 'KATALOG',
title: 'Command-katalog',
lede: '24 kommandoer i 5 fagområder. Filtrer for å finne det du trenger, åpne skjema for å bygge en pipeline-streng.',
verdict: 'n-a',
keyStats: [
{ label: 'KOMMANDOER', value: 24 },
{ label: 'AGENTER', value: 12 },
{ label: 'SKILLS', value: 5 }
]
},
'<div class="catalog-toolbar">' +
'<input type="search" class="input" placeholder="Søk på navn, beskrivelse eller argument-hint…" value="' + escapeAttr(catalogSearchQuery) + '" data-catalog-search aria-label="Søk i katalog">' +
'<span class="catalog-toolbar__count" data-catalog-count>' + countText + '</span>' +
'</div>' +
'<div class="catalog-groups" data-catalog-groups>' + renderCatalogGroupsHtml() + '</div>'
);
root.innerHTML = (
renderTopbar('Katalog') +
'<div class="app-shell app-shell--wide">' +
'<header class="catalog-header">' +
'<h1>Command-katalog</h1>' +
'<p>24 commands gruppert i 5 kategorier. Åpne skjema for å bygge en pipeline-streng som kopieres til terminalen og kjøres med Claude Code.</p>' +
'</header>' +
'<div class="catalog-toolbar">' +
'<input type="search" class="input" placeholder="Søk på navn, beskrivelse eller argument-hint…" value="' + escapeAttr(catalogSearchQuery) + '" data-catalog-search aria-label="Søk i katalog">' +
'<span class="catalog-toolbar__count" data-catalog-count>' + countText + '</span>' +
'</div>' +
'<div class="catalog-groups" data-catalog-groups>' + renderCatalogGroupsHtml() + '</div>' +
catalogShell +
'</div>'
);
}
@ -3873,20 +4010,26 @@
'</div>'
);
const onboardingShell = renderPageShell({
eyebrow: 'ONBOARDING',
title: 'Bli kjent med oss',
lede: 'Oppgi virksomhetskontekst slik at vi kan tilpasse arkitekturråd til din situasjon. 20 felles felter gjenbrukes på tvers av alle commands.',
verdict: 'n-a',
keyStats: []
},
'<div class="onboarding-layout">' +
sidebar +
'<div class="onboarding-main">' +
errorSummary +
'<div class="onboarding-groups">' + groupsHtml + '</div>' +
actionBar +
'</div>' +
'</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 20 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>' +
onboardingShell +
'</div>'
);
}
@ -4137,6 +4280,24 @@
});
};
ACTIONS['project-screen'] = function (ev, el) {
const screen = el.dataset.screen;
if (!screen) return;
currentProjectScreen = screen;
// Toggle aria-current på screen-tabs + data-active på .screen-paneler
// uten full re-render (bevarer evt textarea-input i panels).
const root = getSurfaceEl('project');
if (!root) return;
const tabs = root.querySelectorAll('.screen-tab');
tabs.forEach(function (t) {
t.setAttribute('aria-current', t.dataset.screen === screen ? 'true' : 'false');
});
const screens = root.querySelectorAll('.screen[data-screen-id]');
screens.forEach(function (s) {
s.setAttribute('data-active', s.dataset.screenId === screen ? 'true' : 'false');
});
};
ACTIONS['parse'] = function (ev, el) {
const commandId = el.dataset.command;
if (!commandId) return;