feat(voyage): implement drill-down + back-nav + URL-parameter project

Step 15 (v4.3 Sesjon 3 — Wave 3) — wires the dashboard fleet-tiles
to a drill-down view with breadcrumb update + back-to-dashboard
navigation + browser back/forward restoration via popstate.

renderArtifactDetail(artifactKey) renders the chosen artifact into
the #voyage-detail slot using renderPageShell + renderArtifact:
  - brief / plan / review → markdown render
  - progress              → JSON pretty-print in <pre>
  - research              → list of all research-briefs
  - missing entry         → "Artifact mangler" placeholder

Click delegation on .fleet-tile[data-artifact] triggers detail render
+ pushDetailURL (?artifact=<key>); data-action=back-to-dashboard
returns to the dashboard view + pushDashboardURL. Topbar breadcrumb
gets a third segment for detail views.

URL-parameter deep-linking: at page-load, ?project=<basePath>
surfaces a guide-panel hint explaining that webkitdirectory requires
a user-gesture; the hint links to the same Last prosjektmappe button
that wireProjectLoader already exposes. popstate handler restores
the view-state on browser back/forward (no-op when no project loaded).

Test additions (4): renderArtifactDetail function, URLSearchParams
presence, data-action=back-to-dashboard attribute, popstate listener.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 16:46:13 +02:00
commit 946eb7ab0f
2 changed files with 186 additions and 0 deletions

View file

@ -1422,6 +1422,163 @@ playground first-run shows a complete round-trip-able artifact.
announce('Dashboard lastet — ' + tiles.length + ' artifacts vist.');
}
// ---- v4.3 Step 15 — drill-down + back-nav + URL routing ----------
// Click on a fleet-tile drills into renderArtifactDetail; the
// back-to-dashboard button (or breadcrumb-click) returns to the
// dashboard without state-loss. URL parameter `?project=` is
// additive: at page-load we surface a guide-panel hint because
// webkitdirectory cannot be triggered without a user-gesture.
// popstate handler keeps browser back/forward in sync with view-state.
function resolveArtifactEntry(a, key) {
if (!a) return null;
if (key === 'brief') return a.brief;
if (key === 'plan') return a.plan;
if (key === 'review') return a.review;
if (key === 'progress') return a.progress;
if (key === 'research') return (a.research && a.research.length) ? a.research[0] : null;
return null;
}
function renderArtifactDetail(artifactKey) {
var a = __voyageCurrentArtifacts;
if (!a) return;
var slot = $('voyage-detail');
if (!slot) return;
var entry = resolveArtifactEntry(a, artifactKey);
var bodyHtml;
if (artifactKey === 'research') {
if (a.research && a.research.length) {
bodyHtml = '<ul class="voyage-research-list">' + a.research.map(function (r) {
var fm = r.frontmatter || {};
var title = fm.title || r.path;
return '<li><div class="fleet-tile__name">' + escapeHtml(title) +
'</div><div class="fleet-tile__stat">' + escapeHtml(r.path) + '</div></li>';
}).join('') + '</ul>';
} else {
bodyHtml = '<p><em>Ingen research-briefs i prosjektet.</em></p>';
}
} else if (!entry) {
bodyHtml = '<p><em>Artifact mangler i prosjektmappen.</em></p>';
} else if (artifactKey === 'progress') {
bodyHtml = '<pre><code>' + escapeHtml(entry.content || '') + '</code></pre>';
} else {
bodyHtml = renderArtifact(entry.content || '');
}
var titleMap = { brief: 'Brief', plan: 'Plan', review: 'Review',
research: 'Research', progress: 'Progress' };
var artifactName = titleMap[artifactKey] || artifactKey;
var projectName = shortenBasePath(a.basePath);
slot.innerHTML = renderPageShell({
eyebrow: 'Artifact detail',
title: artifactName,
lede: 'Project: ' + projectName,
meta: entry ? ('path: ' + (entry.path || '')) : 'mangler'
}, '<button type="button" class="voyage-back-btn" data-action="back-to-dashboard" aria-label="Tilbake til dashboard">← Tilbake til dashboard</button>' + bodyHtml);
slot.hidden = false;
// Hide dashboard + paste-flow stages.
var dash = $('voyage-dashboard'); if (dash) dash.hidden = true;
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;
renderTopbar([
{ label: 'Voyage', href: '#' },
{ label: projectName, href: '#' },
{ label: artifactName }
]);
announce('Detail-visning: ' + artifactName);
}
function showDashboardFromState() {
if (!__voyageCurrentArtifacts) return;
var detail = $('voyage-detail'); if (detail) detail.hidden = true;
renderDashboard(__voyageCurrentArtifacts);
}
function pushDashboardURL() {
try {
if (!window.history || !window.history.pushState) return;
var a = __voyageCurrentArtifacts;
var params = new URLSearchParams(window.location.search);
if (a && a.basePath) params.set('project', a.basePath);
params.delete('artifact');
var qs = params.toString();
var url = window.location.pathname + (qs ? ('?' + qs) : '');
window.history.pushState({ view: 'dashboard', basePath: a ? a.basePath : null }, '', url);
} catch (_) { /* file:// + privatmodus */ }
}
function pushDetailURL(artifactKey) {
try {
if (!window.history || !window.history.pushState) return;
var a = __voyageCurrentArtifacts;
var params = new URLSearchParams(window.location.search);
if (a && a.basePath) params.set('project', a.basePath);
params.set('artifact', artifactKey);
var qs = params.toString();
var url = window.location.pathname + (qs ? ('?' + qs) : '');
window.history.pushState({ view: 'detail', basePath: a ? a.basePath : null, artifact: artifactKey }, '', url);
} catch (_) { /* file:// + privatmodus */ }
}
function wireDashboardNavigation() {
document.addEventListener('click', function (e) {
var tile = e.target && e.target.closest && e.target.closest('.fleet-tile[data-artifact]');
if (tile) {
e.preventDefault();
var key = tile.getAttribute('data-artifact');
if (key) {
renderArtifactDetail(key);
pushDetailURL(key);
}
return;
}
var back = e.target && e.target.closest && e.target.closest('[data-action="back-to-dashboard"]');
if (back) {
e.preventDefault();
showDashboardFromState();
pushDashboardURL();
}
});
// Browser back/forward → restore view from history state.
window.addEventListener('popstate', function (e) {
var s = e.state;
if (!s || !__voyageCurrentArtifacts) return;
if (s.view === 'detail' && s.artifact) {
renderArtifactDetail(s.artifact);
} else if (s.view === 'dashboard') {
showDashboardFromState();
}
});
}
function maybeShowProjectURLHint() {
// Caveat per Step 15 spec: webkitdirectory cannot be triggered
// programmatically without a user-gesture, so a `?project=` URL
// surfaces a guide-panel hint instead of attempting auto-load.
try {
var qs = new URLSearchParams(window.location.search);
var projectQ = qs.get('project');
if (!projectQ) return;
var emptyState = $('empty-state');
if (!emptyState) return;
var titleEl = emptyState.querySelector('.guide-panel__title');
var bodyEl = emptyState.querySelector('.guide-panel__body');
if (titleEl) titleEl.textContent = 'Project deep-link oppdaget';
if (bodyEl) {
bodyEl.innerHTML = '<p>URL inneholder <code>?project=' +
escapeHtml(projectQ) + '</code>. Browseren krever et bruker-klikk ' +
'før prosjektmappen kan leses; bruk knappen <strong>«Last prosjektmappe»</strong> ' +
'eller dra mappen til vinduet for å fortsette.</p>';
}
} catch (_) { /* file:// + privatmodus */ }
}
// ---- DOM wiring ----------------------------------------------------
function $(id) { return document.getElementById(id); }
@ -2139,6 +2296,11 @@ playground first-run shows a complete round-trip-able artifact.
// Step 12 (v4.3) — drag-drop overlay with webkitGetAsEntry recursive
// walker + Firefox 150 Windows UA-guard.
wireDragDrop();
// Step 15 (v4.3) — fleet-tile click delegation, back-to-dashboard
// handler, popstate restoration, and ?project= URL deep-link hint.
wireDashboardNavigation();
maybeShowProjectURLHint();
}
function setThemeLabel(theme) {

View file

@ -176,3 +176,27 @@ test('voyage-playground.html declares dashboard status vocabulary (v4.3 Step 14)
assert.match(text, /'missing'/, 'status missing required');
assert.match(text, /'stale'/, 'status stale required');
});
test('voyage-playground.html declares renderArtifactDetail JS function (v4.3 Step 15 drill-down)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function renderArtifactDetail\b/, 'renderArtifactDetail function required for drill-down');
});
test('voyage-playground.html declares URLSearchParams routing (v4.3 Step 15)', () => {
const text = readFileSync(HTML, 'utf-8');
// Presence-only: URLSearchParams already used at line 810 for project-key
// derivation; Step 15 adds ?project= dashboard/detail routing.
assert.match(text, /URLSearchParams/, 'URLSearchParams required for ?project= routing');
});
test('voyage-playground.html declares data-action="back-to-dashboard" (v4.3 Step 15 back-nav)', () => {
const text = readFileSync(HTML, 'utf-8');
// Stricter than Step 14 wording — must appear as data-action attribute
// somewhere in the JS template, not only in HTML comments.
assert.match(text, /data-action="back-to-dashboard"/, 'data-action="back-to-dashboard" required for return-nav handler');
});
test('voyage-playground.html declares popstate handler (v4.3 Step 15 back/forward)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /'popstate'/, 'popstate listener required for browser back/forward');
});