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
|
// shared/nav.js — lateral navigation strip across the project's
|
||||||
// project stages (archive · working · staging · reviewing). Renders
|
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
||||||
// only when:
|
// header"> on DOMContentLoaded, hydrated from the project root's
|
||||||
// 1. location.protocol is http: or https: (online — file:// has no
|
// directory listing.
|
||||||
// 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).
|
|
||||||
//
|
//
|
||||||
// The strip is inserted as a sibling of <header class="app-header">
|
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
||||||
// on DOMContentLoaded — no template changes required. Each tool just
|
// root's JSON listing, filter to entries with `declared: true`
|
||||||
// needs ../shared/nav.{js,css} in its build.sh.
|
// (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
|
// When the fetch fails (offline / no-server / file://), the strip
|
||||||
// stage's default tool; slash opens browse. The stage strip points
|
// falls back to the hardcoded four-stage list so existing
|
||||||
// users at the working tool for each stage:
|
// deployments don't lose chrome. Hardcoded labels in this file are
|
||||||
// archive → <project>/archive (archive tool)
|
// the LAST resort — the cascade is the source of truth in normal
|
||||||
// working → <project>/working (mdedit rooted at working/)
|
// operation.
|
||||||
// staging → <project>/staging (transmittal tool)
|
|
||||||
// reviewing → <project>/reviewing (mdedit at the virtual aggregator)
|
|
||||||
//
|
//
|
||||||
// If a deployment doesn't have one of these folders the server's
|
// Stage URLs follow the slash/no-slash convention: no slash opens
|
||||||
// canonical-folder fallback still lands the user on a usable empty
|
// the stage's default tool. Operators on non-standard layouts can
|
||||||
// view — the strip is convention-driven, not probed. Operators on
|
// override by setting window.zddc.nav.disabled = true before
|
||||||
// non-standard layouts can override by setting
|
// DOMContentLoaded.
|
||||||
// window.zddc.nav.disabled = true before DOMContentLoaded.
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
if (!window.zddc) window.zddc = {};
|
||||||
if (window.zddc.nav) return; // already loaded
|
if (window.zddc.nav) return; // already loaded
|
||||||
|
|
||||||
var STAGES = [
|
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
||||||
{ key: 'archive', label: 'Archive', target: 'archive' },
|
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
||||||
{ key: 'working', label: 'Working', target: 'working' },
|
var FALLBACK_STAGES = [
|
||||||
{ key: 'staging', label: 'Staging', target: 'staging' },
|
{ name: 'archive', label: 'Archive' },
|
||||||
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing' },
|
{ 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) {
|
function projectSegment(pathname) {
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
var parts = pathname.split('/').filter(Boolean);
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
var first = parts[0];
|
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;
|
if (first.indexOf('.') !== -1) return null;
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentStage(pathname) {
|
function currentStage(pathname, stages) {
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
var parts = pathname.split('/').filter(Boolean);
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
var second = parts[1];
|
var second = parts[1];
|
||||||
// <project>/working/... | staging/... | reviewing/... | archive/...
|
for (var i = 0; i < stages.length; i++) {
|
||||||
for (var i = 0; i < STAGES.length; i++) {
|
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
||||||
if (second === STAGES[i].key) return STAGES[i].key;
|
return stages[i].name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// <project>/archive.html → still the archive stage
|
|
||||||
if (second === 'archive.html') return 'archive';
|
if (second === 'archive.html') return 'archive';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +69,53 @@
|
||||||
return projectSegment(location.pathname) !== null;
|
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');
|
var nav = document.createElement('nav');
|
||||||
nav.className = 'zddc-stage-strip';
|
nav.className = 'zddc-stage-strip';
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
nav.setAttribute('aria-label', 'Project stage');
|
||||||
|
|
@ -83,19 +131,19 @@
|
||||||
sep0.textContent = '/';
|
sep0.textContent = '/';
|
||||||
nav.appendChild(sep0);
|
nav.appendChild(sep0);
|
||||||
|
|
||||||
for (var i = 0; i < STAGES.length; i++) {
|
for (var i = 0; i < stages.length; i++) {
|
||||||
var s = STAGES[i];
|
var s = stages[i];
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.className = 'zddc-stage';
|
a.className = 'zddc-stage';
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
|
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
||||||
a.textContent = s.label;
|
a.textContent = s.label;
|
||||||
if (s.key === active) {
|
if (s.name === active) {
|
||||||
a.classList.add('zddc-stage--active');
|
a.classList.add('zddc-stage--active');
|
||||||
a.setAttribute('aria-current', 'page');
|
a.setAttribute('aria-current', 'page');
|
||||||
}
|
}
|
||||||
nav.appendChild(a);
|
nav.appendChild(a);
|
||||||
|
|
||||||
if (i < STAGES.length - 1) {
|
if (i < stages.length - 1) {
|
||||||
var sep = document.createElement('span');
|
var sep = document.createElement('span');
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
sep.className = 'zddc-stage-strip__sep';
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
sep.setAttribute('aria-hidden', 'true');
|
||||||
|
|
@ -107,34 +155,44 @@
|
||||||
return nav;
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount() {
|
function mountWith(project, stages) {
|
||||||
if (!shouldRender()) return;
|
|
||||||
var header = document.querySelector('.app-header');
|
var header = document.querySelector('.app-header');
|
||||||
if (!header) return;
|
if (!header) return;
|
||||||
// Don't double-mount if a tool's main.js calls us a second time.
|
|
||||||
if (header.previousElementSibling &&
|
if (header.previousElementSibling &&
|
||||||
header.previousElementSibling.classList &&
|
header.previousElementSibling.classList &&
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
||||||
return;
|
return; // already mounted
|
||||||
}
|
}
|
||||||
var project = projectSegment(location.pathname);
|
var active = currentStage(location.pathname, stages);
|
||||||
var active = currentStage(location.pathname);
|
var strip = buildStrip(project, active, stages);
|
||||||
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.
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
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 = {
|
window.zddc.nav = {
|
||||||
mount: mount,
|
mount: mount,
|
||||||
// Internals visible for unit tests; do not call from tools.
|
|
||||||
_projectSegment: projectSegment,
|
_projectSegment: projectSegment,
|
||||||
_currentStage: currentStage,
|
_currentStage: currentStage,
|
||||||
_stages: STAGES,
|
_fallbackStages: FALLBACK_STAGES,
|
||||||
// Set to true before DOMContentLoaded to suppress mounting on
|
|
||||||
// deployments where the canonical folder layout doesn't apply.
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
// case-insensitively per entry. Empty map = no overrides.
|
// case-insensitively per entry. Empty map = no overrides.
|
||||||
displayMap := readDisplayMap(absDir)
|
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 {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
||||||
|
|
@ -86,6 +95,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
|
|
||||||
isDir := entry.IsDir()
|
isDir := entry.IsDir()
|
||||||
displayName := lookupDisplay(displayMap, name)
|
displayName := lookupDisplay(displayMap, name)
|
||||||
|
declared := declaredSet[strings.ToLower(name)]
|
||||||
|
|
||||||
if isDir {
|
if isDir {
|
||||||
// ACL check for subdirectory
|
// ACL check for subdirectory
|
||||||
|
|
@ -107,6 +117,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
Mode: uint32(info.Mode()),
|
Mode: uint32(info.Mode()),
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
|
Declared: declared,
|
||||||
}
|
}
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
continue
|
continue
|
||||||
|
|
@ -121,6 +132,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
Mode: uint32(info.Mode()),
|
Mode: uint32(info.Mode()),
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
|
Declared: declared,
|
||||||
}
|
}
|
||||||
result = append(result, fi)
|
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
|
// did this client-side; moving it server-side lets the directory's
|
||||||
// `display:` map apply to virtual entries the same way it applies
|
// `display:` map apply to virtual entries the same way it applies
|
||||||
// to real ones.
|
// to real ones.
|
||||||
result = append(result, virtualCanonicalFolders(fsRoot, dirPath, baseURL, result, displayMap)...)
|
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
|
||||||
|
|
||||||
return result, nil
|
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
|
// operator added via on-disk .zddc paths:). Case-insensitive
|
||||||
// presence check suppresses a virtual entry when the on-disk
|
// presence check suppresses a virtual entry when the on-disk
|
||||||
// directory exists in any case.
|
// 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 {
|
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
|
||||||
|
|
||||||
declared := zddc.ChildrenDeclaredAt(fsRoot, dirPath)
|
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
|
||||||
if len(declared) == 0 {
|
if len(declared) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +194,7 @@ func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
Virtual: true,
|
Virtual: true,
|
||||||
DisplayName: lookupDisplay(displayMap, name),
|
DisplayName: lookupDisplay(displayMap, name),
|
||||||
|
Declared: true, // synthesized entries are by definition cascade-declared
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return synth
|
return synth
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -2392,64 +2392,66 @@ body.help-open .app-header {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/nav.js — lateral navigation strip across the four canonical
|
// shared/nav.js — lateral navigation strip across the project's
|
||||||
// project stages (archive · working · staging · reviewing). Renders
|
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
||||||
// only when:
|
// header"> on DOMContentLoaded, hydrated from the project root's
|
||||||
// 1. location.protocol is http: or https: (online — file:// has no
|
// directory listing.
|
||||||
// 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).
|
|
||||||
//
|
//
|
||||||
// The strip is inserted as a sibling of <header class="app-header">
|
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
||||||
// on DOMContentLoaded — no template changes required. Each tool just
|
// root's JSON listing, filter to entries with `declared: true`
|
||||||
// needs ../shared/nav.{js,css} in its build.sh.
|
// (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
|
// When the fetch fails (offline / no-server / file://), the strip
|
||||||
// stage's default tool; slash opens browse. The stage strip points
|
// falls back to the hardcoded four-stage list so existing
|
||||||
// users at the working tool for each stage:
|
// deployments don't lose chrome. Hardcoded labels in this file are
|
||||||
// archive → <project>/archive (archive tool)
|
// the LAST resort — the cascade is the source of truth in normal
|
||||||
// working → <project>/working (mdedit rooted at working/)
|
// operation.
|
||||||
// staging → <project>/staging (transmittal tool)
|
|
||||||
// reviewing → <project>/reviewing (mdedit at the virtual aggregator)
|
|
||||||
//
|
//
|
||||||
// If a deployment doesn't have one of these folders the server's
|
// Stage URLs follow the slash/no-slash convention: no slash opens
|
||||||
// canonical-folder fallback still lands the user on a usable empty
|
// the stage's default tool. Operators on non-standard layouts can
|
||||||
// view — the strip is convention-driven, not probed. Operators on
|
// override by setting window.zddc.nav.disabled = true before
|
||||||
// non-standard layouts can override by setting
|
// DOMContentLoaded.
|
||||||
// window.zddc.nav.disabled = true before DOMContentLoaded.
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
if (!window.zddc) window.zddc = {};
|
||||||
if (window.zddc.nav) return; // already loaded
|
if (window.zddc.nav) return; // already loaded
|
||||||
|
|
||||||
var STAGES = [
|
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
||||||
{ key: 'archive', label: 'Archive', target: 'archive' },
|
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
||||||
{ key: 'working', label: 'Working', target: 'working' },
|
var FALLBACK_STAGES = [
|
||||||
{ key: 'staging', label: 'Staging', target: 'staging' },
|
{ name: 'archive', label: 'Archive' },
|
||||||
{ key: 'reviewing', label: 'Reviewing', target: 'reviewing' },
|
{ 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) {
|
function projectSegment(pathname) {
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
var parts = pathname.split('/').filter(Boolean);
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
var first = parts[0];
|
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;
|
if (first.indexOf('.') !== -1) return null;
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentStage(pathname) {
|
function currentStage(pathname, stages) {
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
var parts = pathname.split('/').filter(Boolean);
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
var second = parts[1];
|
var second = parts[1];
|
||||||
// <project>/working/... | staging/... | reviewing/... | archive/...
|
for (var i = 0; i < stages.length; i++) {
|
||||||
for (var i = 0; i < STAGES.length; i++) {
|
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
||||||
if (second === STAGES[i].key) return STAGES[i].key;
|
return stages[i].name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// <project>/archive.html → still the archive stage
|
|
||||||
if (second === 'archive.html') return 'archive';
|
if (second === 'archive.html') return 'archive';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -2461,7 +2463,53 @@ body.help-open .app-header {
|
||||||
return projectSegment(location.pathname) !== null;
|
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');
|
var nav = document.createElement('nav');
|
||||||
nav.className = 'zddc-stage-strip';
|
nav.className = 'zddc-stage-strip';
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
nav.setAttribute('aria-label', 'Project stage');
|
||||||
|
|
@ -2477,19 +2525,19 @@ body.help-open .app-header {
|
||||||
sep0.textContent = '/';
|
sep0.textContent = '/';
|
||||||
nav.appendChild(sep0);
|
nav.appendChild(sep0);
|
||||||
|
|
||||||
for (var i = 0; i < STAGES.length; i++) {
|
for (var i = 0; i < stages.length; i++) {
|
||||||
var s = STAGES[i];
|
var s = stages[i];
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.className = 'zddc-stage';
|
a.className = 'zddc-stage';
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
|
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
||||||
a.textContent = s.label;
|
a.textContent = s.label;
|
||||||
if (s.key === active) {
|
if (s.name === active) {
|
||||||
a.classList.add('zddc-stage--active');
|
a.classList.add('zddc-stage--active');
|
||||||
a.setAttribute('aria-current', 'page');
|
a.setAttribute('aria-current', 'page');
|
||||||
}
|
}
|
||||||
nav.appendChild(a);
|
nav.appendChild(a);
|
||||||
|
|
||||||
if (i < STAGES.length - 1) {
|
if (i < stages.length - 1) {
|
||||||
var sep = document.createElement('span');
|
var sep = document.createElement('span');
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
sep.className = 'zddc-stage-strip__sep';
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
sep.setAttribute('aria-hidden', 'true');
|
||||||
|
|
@ -2501,34 +2549,44 @@ body.help-open .app-header {
|
||||||
return nav;
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount() {
|
function mountWith(project, stages) {
|
||||||
if (!shouldRender()) return;
|
|
||||||
var header = document.querySelector('.app-header');
|
var header = document.querySelector('.app-header');
|
||||||
if (!header) return;
|
if (!header) return;
|
||||||
// Don't double-mount if a tool's main.js calls us a second time.
|
|
||||||
if (header.previousElementSibling &&
|
if (header.previousElementSibling &&
|
||||||
header.previousElementSibling.classList &&
|
header.previousElementSibling.classList &&
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
||||||
return;
|
return; // already mounted
|
||||||
}
|
}
|
||||||
var project = projectSegment(location.pathname);
|
var active = currentStage(location.pathname, stages);
|
||||||
var active = currentStage(location.pathname);
|
var strip = buildStrip(project, active, stages);
|
||||||
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.
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
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 = {
|
window.zddc.nav = {
|
||||||
mount: mount,
|
mount: mount,
|
||||||
// Internals visible for unit tests; do not call from tools.
|
|
||||||
_projectSegment: projectSegment,
|
_projectSegment: projectSegment,
|
||||||
_currentStage: currentStage,
|
_currentStage: currentStage,
|
||||||
_stages: STAGES,
|
_fallbackStages: FALLBACK_STAGES,
|
||||||
// Set to true before DOMContentLoaded to suppress mounting on
|
|
||||||
// deployments where the canonical folder layout doesn't apply.
|
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,13 @@ type FileInfo struct {
|
||||||
// basename). Empty = render Name. Never empty for an explicit
|
// basename). Empty = render Name. Never empty for an explicit
|
||||||
// override — clients shouldn't infer a default from absence here.
|
// override — clients shouldn't infer a default from absence here.
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
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