The shared/nav.js stage strip previously hardcoded four stages
(archive/working/staging/reviewing) with their labels and target
URLs baked into the file. Operators couldn't add a fifth stage or
rename "Working" to "In-Progress" without forking shared code.
Now cascade-driven end-to-end:
Server-side:
listing.FileInfo gains a Declared bool field. fs.ListDirectory
stamps Declared=true on every entry whose name matches the
cascade's ChildrenDeclaredAt(parent) — both real on-disk dirs
and virtual canonical injections. Bugfix in the same patch:
virtualCanonicalFolders was passing the relative dirPath to
ChildrenDeclaredAt (which expects absolute); now passes absDir.
Client-side:
shared/nav.js fetches the project root's JSON listing on
DOMContentLoaded, filters to declared+is_dir entries, sorts by
canonical workflow order (archive → working → staging →
reviewing, then any extras alphabetically), and renders the
strip. Labels read e.display_name → falls back to titleCase(name).
Hardcoded FALLBACK_STAGES kicks in only on fetch failure
(offline / file:// / non-zddc-server backend). Rendered
immediately so the strip appears without flicker, then the
cascade-fetched list replaces it once available.
Effect:
Project-3 (which has display: { archive: "Records",
working: "In-Progress", ... } in its .zddc) now shows
"Records · In-Progress · Outbox · Pending Responses" in every
tool's strip. Project-1 still shows "Archive · Working ·
Staging · Reviewing". No code change to render either; the
cascade decides.
Tests:
- tests/nav.spec.js relies on the mock server returning HTML at
every URL, so the fetch fails over to fallback stages — the
test renders the same Archive/Working/Staging/Reviewing labels
it always did, with no test changes needed.
- All 248 Playwright + all Go tests green.
Remaining client-side hardcode: archive/js/source.js +
archive/js/app.js's mode detection. Phase 4d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
7.6 KiB
JavaScript
204 lines
7.6 KiB
JavaScript
// shared/nav.js — lateral navigation strip across the project's
|
|
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
// directory listing.
|
|
//
|
|
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
// root's JSON listing, filter to entries with `declared: true`
|
|
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
// render in canonical workflow order with display_name overrides
|
|
// honored. An operator who edits the project's .zddc paths: to add
|
|
// a new declared child sees it in the strip; one who removes a
|
|
// canonical entry sees the strip drop it.
|
|
//
|
|
// When the fetch fails (offline / no-server / file://), the strip
|
|
// falls back to the hardcoded four-stage list so existing
|
|
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
// the LAST resort — the cascade is the source of truth in normal
|
|
// operation.
|
|
//
|
|
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
// the stage's default tool. Operators on non-standard layouts can
|
|
// override by setting window.zddc.nav.disabled = true before
|
|
// DOMContentLoaded.
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!window.zddc) window.zddc = {};
|
|
if (window.zddc.nav) return; // already loaded
|
|
|
|
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
var FALLBACK_STAGES = [
|
|
{ name: 'archive', label: 'Archive' },
|
|
{ name: 'working', label: 'Working' },
|
|
{ name: 'staging', label: 'Staging' },
|
|
{ name: 'reviewing', label: 'Reviewing' },
|
|
];
|
|
|
|
// Canonical workflow order. Stages appearing in this list are
|
|
// rendered in this order; any extras the cascade declares are
|
|
// appended alphabetically.
|
|
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
|
|
function projectSegment(pathname) {
|
|
var parts = pathname.split('/').filter(Boolean);
|
|
if (parts.length === 0) return null;
|
|
var first = parts[0];
|
|
if (first.indexOf('.') !== -1) return null;
|
|
return first;
|
|
}
|
|
|
|
function currentStage(pathname, stages) {
|
|
var parts = pathname.split('/').filter(Boolean);
|
|
if (parts.length < 2) return null;
|
|
var second = parts[1];
|
|
for (var i = 0; i < stages.length; i++) {
|
|
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
return stages[i].name;
|
|
}
|
|
}
|
|
if (second === 'archive.html') return 'archive';
|
|
return null;
|
|
}
|
|
|
|
function shouldRender() {
|
|
if (typeof location === 'undefined') return false;
|
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
return projectSegment(location.pathname) !== null;
|
|
}
|
|
|
|
function titleCase(s) {
|
|
if (!s) return s;
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
function sortByWorkflow(stages) {
|
|
return stages.slice().sort(function (a, b) {
|
|
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
if (ia >= 0) return -1;
|
|
if (ib >= 0) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
// Fetch the project root listing and extract declared stage
|
|
// entries. Returns [] on any error so callers fall back to the
|
|
// hardcoded list. Each stage entry is {name, label} — label
|
|
// honors the cascade's display: override when present.
|
|
async function fetchStagesFor(project) {
|
|
try {
|
|
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!resp.ok) return [];
|
|
var data = await resp.json();
|
|
if (!Array.isArray(data)) return [];
|
|
var stages = [];
|
|
for (var i = 0; i < data.length; i++) {
|
|
var e = data[i];
|
|
if (!e || !e.declared || !e.is_dir) continue;
|
|
var bare = (e.name || '').replace(/\/$/, '');
|
|
if (!bare) continue;
|
|
stages.push({
|
|
name: bare,
|
|
label: e.display_name || titleCase(bare),
|
|
});
|
|
}
|
|
return sortByWorkflow(stages);
|
|
} catch (_e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function buildStrip(project, active, stages) {
|
|
var nav = document.createElement('nav');
|
|
nav.className = 'zddc-stage-strip';
|
|
nav.setAttribute('aria-label', 'Project stage');
|
|
|
|
var label = document.createElement('span');
|
|
label.className = 'zddc-stage-strip__project';
|
|
label.textContent = project;
|
|
nav.appendChild(label);
|
|
|
|
var sep0 = document.createElement('span');
|
|
sep0.className = 'zddc-stage-strip__divider';
|
|
sep0.setAttribute('aria-hidden', 'true');
|
|
sep0.textContent = '/';
|
|
nav.appendChild(sep0);
|
|
|
|
for (var i = 0; i < stages.length; i++) {
|
|
var s = stages[i];
|
|
var a = document.createElement('a');
|
|
a.className = 'zddc-stage';
|
|
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
a.textContent = s.label;
|
|
if (s.name === active) {
|
|
a.classList.add('zddc-stage--active');
|
|
a.setAttribute('aria-current', 'page');
|
|
}
|
|
nav.appendChild(a);
|
|
|
|
if (i < stages.length - 1) {
|
|
var sep = document.createElement('span');
|
|
sep.className = 'zddc-stage-strip__sep';
|
|
sep.setAttribute('aria-hidden', 'true');
|
|
sep.textContent = '·';
|
|
nav.appendChild(sep);
|
|
}
|
|
}
|
|
|
|
return nav;
|
|
}
|
|
|
|
function mountWith(project, stages) {
|
|
var header = document.querySelector('.app-header');
|
|
if (!header) return;
|
|
if (header.previousElementSibling &&
|
|
header.previousElementSibling.classList &&
|
|
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
return; // already mounted
|
|
}
|
|
var active = currentStage(location.pathname, stages);
|
|
var strip = buildStrip(project, active, stages);
|
|
header.parentNode.insertBefore(strip, header);
|
|
}
|
|
|
|
async function mount() {
|
|
if (!shouldRender()) return;
|
|
var project = projectSegment(location.pathname);
|
|
if (!project) return;
|
|
|
|
// Render the hardcoded fallback immediately so the strip
|
|
// appears with no flicker, then upgrade to cascade-resolved
|
|
// stages once the fetch completes.
|
|
mountWith(project, FALLBACK_STAGES);
|
|
|
|
var fetched = await fetchStagesFor(project);
|
|
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
|
|
// Replace the strip with the cascade-driven one. Remove the
|
|
// existing strip first so mountWith re-mounts cleanly.
|
|
var existing = document.querySelector('.zddc-stage-strip');
|
|
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
mountWith(project, fetched);
|
|
}
|
|
|
|
window.zddc.nav = {
|
|
mount: mount,
|
|
_projectSegment: projectSegment,
|
|
_currentStage: currentStage,
|
|
_fallbackStages: FALLBACK_STAGES,
|
|
disabled: false,
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
} else {
|
|
mount();
|
|
}
|
|
})();
|