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:
parent
d90975662f
commit
9c7858c60a
4 changed files with 254 additions and 116 deletions
170
shared/nav.js
170
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 <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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue