From 9c7858c60a6e36ab9001126f6cb2d54242ec67f4 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 11 May 2026 16:34:56 -0500 Subject: [PATCH] =?UTF-8?q?feat(zddc):=20Phase=204c=20=E2=80=94=20stage=20?= =?UTF-8?q?strip=20driven=20by=20cascade-declared=20children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- shared/nav.js | 170 +++++++++++++++++++---------- zddc/internal/fs/tree.go | 19 +++- zddc/internal/handler/tables.html | 172 ++++++++++++++++++++---------- zddc/internal/listing/types.go | 9 ++ 4 files changed, 254 insertions(+), 116 deletions(-) diff --git a/shared/nav.js b/shared/nav.js index f72f3dc..745fe93 100644 --- a/shared/nav.js +++ b/shared/nav.js @@ -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
on DOMContentLoaded, hydrated from the project root's +// directory listing. // -// The strip is inserted as a sibling of
-// 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 → /archive (archive tool) -// working → /working (mdedit rooted at working/) -// staging → /staging (transmittal tool) -// reviewing → /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]; - // /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; + } } - // /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, }; diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 0f6051d..69433d5 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -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 diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index a0e41e3..0c45836 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 21:14:28 · 4b04f61-dirty + v0.0.17-alpha · 2026-05-11 21:32:54 · d909756-dirty
@@ -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
on DOMContentLoaded, hydrated from the project root's +// directory listing. // -// The strip is inserted as a sibling of
-// 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 → /archive (archive tool) -// working → /working (mdedit rooted at working/) -// staging → /staging (transmittal tool) -// reviewing → /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]; - // /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; + } } - // /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, }; diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 877c94c..b1762d8 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -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"` }