feat(voyage): implement dashboard via fleet-grid + fleet-tile with status vocabulary

Step 14 (v4.3 Sesjon 3 — Wave 3) — adds renderDashboard pipeline that
turns a ProjectArtifacts struct (produced by loadProjectDirectory in
Step 13) into a fleet-grid of fleet-tiles, one per artifact-type
(brief / plan / review / research / progress).

Status vocabulary: complete, in-progress, blocked, missing, stale
Severity mapping: missing → critical, blocked → high, in-progress
+ stale → medium, complete → low. Severity drives DS color tokens
via [data-severity] attribute selectors.

When loadProjectDirectory completes, dashboard takes over the main
stage (paste-flow elements hidden); topbar updates with project
breadcrumb. Step 13's pipeline already calls renderDashboard via
graceful-fallback, so wiring is automatic.

Test additions (4): fleet-grid + fleet-tile presence, renderDashboard
function declaration, status vocabulary completeness.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 16:43:22 +02:00
commit a479f47b4e
2 changed files with 221 additions and 0 deletions

View file

@ -535,6 +535,48 @@
word-break: break-all;
margin-bottom: var(--space-3);
}
/* v4.3 Step 14 — dashboard fleet-grid stage. Tiles inherit DS
fleet-tile typography from components-tier3-supplement.css; severity
badge inherits DS color-severity-* tokens. data-severity drives
border + badge color via attribute selectors below. */
#voyage-dashboard[hidden],
#voyage-detail[hidden] { display: none; }
.voyage-dashboard__page,
.voyage-detail__page { padding: 0; }
.fleet-tile[data-severity="critical"] { border-left: 3px solid var(--color-severity-critical); }
.fleet-tile[data-severity="high"] { border-left: 3px solid var(--color-severity-high); }
.fleet-tile[data-severity="medium"] { border-left: 3px solid var(--color-severity-medium); }
.fleet-tile[data-severity="low"] { border-left: 3px solid var(--color-state-success); }
.fleet-tile__status-badge {
display: inline-block;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.02em;
text-transform: uppercase;
color: #fff;
}
.fleet-tile__status-badge[data-severity="critical"] { background: var(--color-severity-critical); }
.fleet-tile__status-badge[data-severity="high"] { background: var(--color-severity-high); }
.fleet-tile__status-badge[data-severity="medium"] { background: var(--color-severity-medium); color: #1a1a1a; }
.fleet-tile__status-badge[data-severity="low"] { background: var(--color-state-success); }
.fleet-tile__stat {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.voyage-back-btn {
margin-bottom: var(--space-4);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
font: inherit;
}
.voyage-back-btn:hover { background: var(--color-bg-soft); }
</style>
</head>
<body>
@ -582,6 +624,17 @@
</div>
</section>
<!-- v4.3 Step 14 — Project dashboard mount slot. Hidden until
loadProjectDirectory completes; renderDashboard fills with a
fleet-grid of fleet-tiles (one per artifact: brief / plan /
review / research / progress) plus status vocabulary badges. -->
<section id="voyage-dashboard" class="voyage-dashboard__page" aria-label="Project dashboard" hidden></section>
<!-- v4.3 Step 15 — Artifact-detail mount slot. Hidden until a
fleet-tile is clicked; back-to-dashboard returns to dashboard
without state-loss. -->
<section id="voyage-detail" class="voyage-detail__page" aria-label="Artifact detail" hidden></section>
<section class="paste-import-row" aria-label="Import artifact for annotation">
<label for="voyage-paste-input" class="visually-hidden">Lim inn artifact-innhold (brief.md / plan.md / review.md)</label>
<textarea
@ -1228,6 +1281,147 @@ playground first-run shows a complete round-trip-able artifact.
return parts.join('');
}
// ---- v4.3 Step 14 — renderDashboard --------------------------------
// Build a fleet-grid from a ProjectArtifacts struct (produced by
// loadProjectDirectory in Step 13). Each artifact becomes a
// fleet-tile with status vocabulary + severity mapping:
// complete → low (green)
// in-progress → medium (amber)
// stale → medium
// blocked → high (orange)
// missing → critical (red)
// Clicking a tile triggers drill-down (Step 15).
var __voyageCurrentArtifacts = null;
function mapStatusToSeverity(status) {
if (status === 'missing') return 'critical';
if (status === 'blocked') return 'high';
if (status === 'in-progress' || status === 'stale') return 'medium';
return 'low';
}
function deriveArtifactStatus(entry, key) {
if (!entry) return 'missing';
var fm = entry.frontmatter || {};
if (key === 'brief') {
if (fm.brief_quality === 'partial') return 'in-progress';
return 'complete';
}
if (key === 'plan') {
if (fm.status === 'partial' || fm.status === 'in-progress') return 'in-progress';
return 'complete';
}
if (key === 'review') {
if (fm.verdict === 'BLOCK' || fm.verdict === 'REPLAN') return 'blocked';
return 'complete';
}
if (key === 'progress') {
var status = '';
try { status = JSON.parse(entry.content || '{}').status || ''; } catch (_) { status = ''; }
if (status === 'in-progress') return 'in-progress';
if (status === 'failed' || status === 'stopped') return 'blocked';
if (status === 'partial') return 'stale';
if (status === 'completed') return 'complete';
return 'in-progress';
}
return 'complete';
}
function buildArtifactKeyStat(entry, key, researchCount) {
if (key === 'research') {
return researchCount + ' research-brief' + (researchCount === 1 ? '' : 's');
}
if (!entry) return '—';
var fm = entry.frontmatter || {};
if (key === 'brief') return 'Quality: ' + (fm.brief_quality || 'complete');
if (key === 'plan') return 'Profile: ' + (fm.profile || '—');
if (key === 'review') return 'Verdict: ' + (fm.verdict || '—');
if (key === 'progress') {
var s = '';
try { s = JSON.parse(entry.content || '{}').status || ''; } catch (_) { s = ''; }
return 'Status: ' + (s || '—');
}
return 'OK';
}
function buildArtifactTiles(a) {
var tiles = [];
var defs = [
{ key: 'brief', title: 'Brief', entry: a.brief },
{ key: 'plan', title: 'Plan', entry: a.plan },
{ key: 'review', title: 'Review', entry: a.review },
{ key: 'research', title: 'Research', entry: (a.research && a.research.length) ? a.research[0] : null },
{ key: 'progress', title: 'Progress', entry: a.progress }
];
for (var i = 0; i < defs.length; i++) {
var d = defs[i];
var status;
if (d.key === 'research') {
status = (a.research && a.research.length) ? 'complete' : 'missing';
} else {
status = deriveArtifactStatus(d.entry, d.key);
}
tiles.push({
key: d.key,
title: d.title,
status: status,
severity: mapStatusToSeverity(status),
keyStat: buildArtifactKeyStat(d.entry, d.key, a.research ? a.research.length : 0)
});
}
return tiles;
}
function shortenBasePath(p) {
if (!p) return '(unnamed)';
var parts = String(p).split('/');
return parts[parts.length - 1] || p;
}
function renderDashboard(projectArtifacts, slot) {
var host = slot || $('voyage-dashboard');
if (!host) return;
__voyageCurrentArtifacts = projectArtifacts;
var tiles = buildArtifactTiles(projectArtifacts);
var tilesHtml = tiles.map(function (t) {
return '<a class="fleet-tile" data-artifact="' + t.key +
'" data-status="' + t.status + '" data-severity="' + t.severity +
'" href="#" tabindex="0" role="link" aria-label="' +
escapeHtml(t.title + ': ' + t.status) + '">' +
'<div class="fleet-tile__row">' +
'<span class="fleet-tile__name">' + escapeHtml(t.title) + '</span>' +
'<span class="fleet-tile__status-badge" data-severity="' + t.severity + '">' +
escapeHtml(t.status) + '</span>' +
'</div>' +
'<div class="fleet-tile__stat">' + escapeHtml(t.keyStat) + '</div>' +
'</a>';
}).join('');
var projectName = shortenBasePath(projectArtifacts.basePath);
var bodyHtml = '<div class="fleet-grid">' + tilesHtml + '</div>';
host.innerHTML = renderPageShell({
eyebrow: 'Project dashboard',
title: projectName,
lede: tiles.length + ' artifacts oppdaget i prosjektmappen.',
meta: 'Storage-key: ' + (projectArtifacts.storageKey || '—')
}, bodyHtml);
host.hidden = false;
// Hide paste-flow stage; dashboard takes over.
var emptyState = $('empty-state'); if (emptyState) emptyState.hidden = true;
var pasteRow = document.querySelector('.paste-import-row'); if (pasteRow) pasteRow.hidden = true;
var layout = document.querySelector('.voyage-layout'); if (layout) layout.hidden = true;
var detail = $('voyage-detail'); if (detail) detail.hidden = true;
// Update topbar with project breadcrumb.
renderTopbar([
{ label: 'Voyage', href: '#' },
{ label: projectName }
]);
announce('Dashboard lastet — ' + tiles.length + ' artifacts vist.');
}
// ---- DOM wiring ----------------------------------------------------
function $(id) { return document.getElementById(id); }

View file

@ -149,3 +149,30 @@ test('voyage-playground.html uses clipboard.writeText for copy flow (Step 11 exp
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /clipboard\.writeText/, 'navigator.clipboard.writeText path required for command-copy');
});
// --- v4.3 Sesjon 3 — Step 14 (dashboard) + Step 15 (drill-down + URL routing) ----
test('voyage-playground.html declares fleet-grid container (v4.3 Step 14 dashboard)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /fleet-grid/, 'fleet-grid container required for dashboard layout');
});
test('voyage-playground.html declares fleet-tile (v4.3 Step 14 dashboard)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /fleet-tile/, 'fleet-tile required for per-artifact dashboard cell');
});
test('voyage-playground.html declares renderDashboard JS function (v4.3 Step 14)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function renderDashboard\b/, 'renderDashboard function required');
});
test('voyage-playground.html declares dashboard status vocabulary (v4.3 Step 14)', () => {
const text = readFileSync(HTML, 'utf-8');
// Status vocabulary per plan: complete, in-progress, blocked, missing, stale
assert.match(text, /'complete'/, 'status complete required');
assert.match(text, /'in-progress'/, 'status in-progress required');
assert.match(text, /'blocked'/, 'status blocked required');
assert.match(text, /'missing'/, 'status missing required');
assert.match(text, /'stale'/, 'status stale required');
});