feat(zddc): Phase 4c — stage strip driven by cascade-declared children

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>
This commit is contained in:
ZDDC 2026-05-11 16:34:56 -05:00
parent d90975662f
commit 9c7858c60a
4 changed files with 254 additions and 116 deletions

View file

@ -1,61 +1,63 @@
// shared/nav.js — lateral navigation strip across the four canonical
// project stages (archive · working · staging · reviewing). Renders
// only when:
// 1. location.protocol is http: or https: (online — file:// has no
// project structure to navigate within), AND
// 2. a project segment can be detected from location.pathname (the
// first path segment, when it isn't a tool HTML file).
// 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.
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
// 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.
//
// Stage URLs follow the slash/no-slash convention: no slash opens the
// stage's default tool; slash opens browse. The stage strip points
// users at the working tool for each stage:
// archive → <project>/archive (archive tool)
// working → <project>/working (mdedit rooted at working/)
// staging → <project>/staging (transmittal tool)
// reviewing → <project>/reviewing (mdedit at the virtual aggregator)
// 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.
//
// If a deployment doesn't have one of these folders the server's
// canonical-folder fallback still lands the user on a usable empty
// view — the strip is convention-driven, not probed. Operators on
// non-standard layouts can override by setting
// window.zddc.nav.disabled = true before DOMContentLoaded.
// 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
var STAGES = [
{ key: 'archive', label: 'Archive', target: 'archive' },
{ key: 'working', label: 'Working', target: 'working' },
{ key: 'staging', label: 'Staging', target: 'staging' },
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing' },
// 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];
// At deployment root (e.g. /archive.html?projects=A,B or
// /index.html) the first segment is a tool HTML — no single
// project to scope the strip to.
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname) {
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
@ -67,7 +69,53 @@
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
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');
@ -83,19 +131,19 @@
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
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.target;
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.key === active) {
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
@ -107,34 +155,44 @@
return nav;
}
function mount() {
if (!shouldRender()) return;
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
// Don't double-mount if a tool's main.js calls us a second time.
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return;
return; // already mounted
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
// Mount ABOVE the header — the strip is project-level chrome
// (where in the project), the header is tool-level chrome (which
// tool, theme, help). Reading order matches outer-to-inner scope.
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
// Expose for tests + opt-out.
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,
// Internals visible for unit tests; do not call from tools.
_projectSegment: projectSegment,
_currentStage: currentStage,
_stages: STAGES,
// Set to true before DOMContentLoaded to suppress mounting on
// deployments where the canonical folder layout doesn't apply.
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};

View file

@ -71,6 +71,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// case-insensitively per entry. Empty map = no overrides.
displayMap := readDisplayMap(absDir)
// Set of cascade-declared child names (lowercase) for this dir.
// Entries with a matching name get Declared=true so clients can
// pick out the canonical-convention children without
// re-implementing the cascade.
declaredSet := make(map[string]bool)
for _, name := range zddc.ChildrenDeclaredAt(fsRoot, absDir) {
declaredSet[strings.ToLower(name)] = true
}
for _, entry := range entries {
name := entry.Name()
@ -86,6 +95,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
isDir := entry.IsDir()
displayName := lookupDisplay(displayMap, name)
declared := declaredSet[strings.ToLower(name)]
if isDir {
// ACL check for subdirectory
@ -107,6 +117,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
Mode: uint32(info.Mode()),
IsDir: true,
DisplayName: displayName,
Declared: declared,
}
result = append(result, fi)
continue
@ -121,6 +132,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
Mode: uint32(info.Mode()),
IsDir: false,
DisplayName: displayName,
Declared: declared,
}
result = append(result, fi)
}
@ -140,7 +152,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// did this client-side; moving it server-side lets the directory's
// `display:` map apply to virtual entries the same way it applies
// to real ones.
result = append(result, virtualCanonicalFolders(fsRoot, dirPath, baseURL, result, displayMap)...)
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
return result, nil
}
@ -154,10 +166,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// operator added via on-disk .zddc paths:). Case-insensitive
// presence check suppresses a virtual entry when the on-disk
// directory exists in any case.
func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
declared := zddc.ChildrenDeclaredAt(fsRoot, dirPath)
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
if len(declared) == 0 {
return nil
}
@ -182,6 +194,7 @@ func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
IsDir: true,
Virtual: true,
DisplayName: lookupDisplay(displayMap, name),
Declared: true, // synthesized entries are by definition cascade-declared
})
}
return synth

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 21:14:28 · 4b04f61-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 21:32:54 · d909756-dirty</span></span>
</div>
</div>
<div class="header-right">
@ -2392,64 +2392,66 @@ body.help-open .app-header {
}
})();
// shared/nav.js — lateral navigation strip across the four canonical
// project stages (archive · working · staging · reviewing). Renders
// only when:
// 1. location.protocol is http: or https: (online — file:// has no
// project structure to navigate within), AND
// 2. a project segment can be detected from location.pathname (the
// first path segment, when it isn't a tool HTML file).
// 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.
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
// 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.
//
// Stage URLs follow the slash/no-slash convention: no slash opens the
// stage's default tool; slash opens browse. The stage strip points
// users at the working tool for each stage:
// archive → <project>/archive (archive tool)
// working → <project>/working (mdedit rooted at working/)
// staging → <project>/staging (transmittal tool)
// reviewing → <project>/reviewing (mdedit at the virtual aggregator)
// 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.
//
// If a deployment doesn't have one of these folders the server's
// canonical-folder fallback still lands the user on a usable empty
// view — the strip is convention-driven, not probed. Operators on
// non-standard layouts can override by setting
// window.zddc.nav.disabled = true before DOMContentLoaded.
// 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
var STAGES = [
{ key: 'archive', label: 'Archive', target: 'archive' },
{ key: 'working', label: 'Working', target: 'working' },
{ key: 'staging', label: 'Staging', target: 'staging' },
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing' },
// 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];
// At deployment root (e.g. /archive.html?projects=A,B or
// /index.html) the first segment is a tool HTML — no single
// project to scope the strip to.
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname) {
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
@ -2461,7 +2463,53 @@ body.help-open .app-header {
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
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');
@ -2477,19 +2525,19 @@ body.help-open .app-header {
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
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.target;
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.key === active) {
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
@ -2501,34 +2549,44 @@ body.help-open .app-header {
return nav;
}
function mount() {
if (!shouldRender()) return;
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
// Don't double-mount if a tool's main.js calls us a second time.
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return;
return; // already mounted
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
// Mount ABOVE the header — the strip is project-level chrome
// (where in the project), the header is tool-level chrome (which
// tool, theme, help). Reading order matches outer-to-inner scope.
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
// Expose for tests + opt-out.
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,
// Internals visible for unit tests; do not call from tools.
_projectSegment: projectSegment,
_currentStage: currentStage,
_stages: STAGES,
// Set to true before DOMContentLoaded to suppress mounting on
// deployments where the canonical folder layout doesn't apply.
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};

View file

@ -28,4 +28,13 @@ type FileInfo struct {
// basename). Empty = render Name. Never empty for an explicit
// override — clients shouldn't infer a default from absence here.
DisplayName string `json:"display_name,omitempty"`
// Declared marks this entry as one the .zddc cascade names via
// the parent's Paths: map — i.e. it's a "known" child of this
// directory, with rules already defined. Used by clients (e.g.
// shared/nav.js's stage strip) to filter the project root's
// listing down to the canonical stages without hardcoding the
// names. Real on-disk dirs and virtual placeholders both get
// Declared=true when their name matches the cascade.
Declared bool `json:"declared,omitempty"`
}