ui: fix admin-mode frame; drop project-stage strip

Three UI cleanups against the admin/browse chrome.

Red admin-mode frame (shared/elevation.css)
  Was: body { outline: 3px ... ; outline-offset: -3px } — an outline
  doesn't reflow content, so in tools that butt their content to the
  viewport edge (browse split-pane, archive grid) the frame painted
  on top of the first 3px of content.
  Now: body.is-elevated::after { position:fixed; inset:0; border:3px;
  pointer-events:none; z-index:9200 }. The frame lives in its own
  fixed layer above all content, so it never overlaps or steals
  clicks; content layout is unchanged.

Project-stage strip (Archive · Working · Staging · Reviewing)
  Low-value chrome. Removed entirely:
    - delete shared/nav.js + shared/nav.css
    - drop the include from every tool's build.sh
      (browse, transmittal, form, archive, landing, tables, classifier)
    - delete tests/nav.spec.js
    - rebuild tables.html (the //go:embed'd baked-in copy)
  Project navigation already happens through the directory tree in
  browse and the URL bar; the strip duplicated breadcrumb information
  without adding capability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 16:39:35 -05:00
parent cff840e225
commit 19566360a6
12 changed files with 29 additions and 638 deletions

2
archive/build.sh Normal file → Executable file
View file

@ -23,7 +23,6 @@ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"css/base.css" \ "css/base.css" \
"css/layout.css" \ "css/layout.css" \
@ -47,7 +46,6 @@ concat_files \
"../shared/zip-source.js" \ "../shared/zip-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/init.js" \ "js/init.js" \

View file

@ -24,7 +24,6 @@ concat_files \
"../shared/fonts.css" \ "../shared/fonts.css" \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \ "../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \ "../shared/vendor/codemirror-yaml.min.css" \
@ -51,7 +50,6 @@ concat_files \
"../shared/zip-source.js" \ "../shared/zip-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/help.js" \ "../shared/help.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \

2
classifier/build.sh Normal file → Executable file
View file

@ -23,7 +23,6 @@ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"css/base.css" \ "css/base.css" \
"css/layout.css" \ "css/layout.css" \
@ -45,7 +44,6 @@ concat_files \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \

View file

@ -22,7 +22,6 @@ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"css/form.css" \ "css/form.css" \
> "$css_temp" > "$css_temp"
@ -30,7 +29,6 @@ concat_files \
concat_files \ concat_files \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/help.js" \ "../shared/help.js" \
"../shared/elevation.js" \ "../shared/elevation.js" \

View file

@ -22,7 +22,6 @@ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"css/landing.css" \ "css/landing.css" \
> "$css_temp" > "$css_temp"
@ -32,7 +31,6 @@ concat_files \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/help.js" \ "../shared/help.js" \
"../shared/elevation.js" \ "../shared/elevation.js" \

View file

@ -48,16 +48,25 @@
/* Page-wide chrome when admin mode is active. The toggle alone is /* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue: easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire page (body) peripheral- 1. Thin red border around the entire viewport peripheral-
vision reminder regardless of which tool / scroll position. vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin" 2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle. button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared. */ load and tears it down when elevation is cleared.
body.is-elevated {
outline: 3px solid var(--danger, #dc3545); Frame uses fixed positioning + pointer-events:none so it doesn't
outline-offset: -3px; reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
} }
.elevation-banner { .elevation-banner {

View file

@ -1,56 +0,0 @@
/* shared/nav.css lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}

View file

@ -1,204 +0,0 @@
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();

View file

@ -22,7 +22,6 @@ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"../shared/context-menu.css" \ "../shared/context-menu.css" \
"css/table.css" \ "css/table.css" \
@ -40,7 +39,6 @@ concat_files \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/help.js" \ "../shared/help.js" \
"../shared/elevation.js" \ "../shared/elevation.js" \

View file

@ -1,91 +0,0 @@
// Tests for shared/nav.js — the lateral project-stage strip.
//
// The strip's render decision depends on location.protocol and
// location.pathname. file:// won't render at all (online-only). To
// exercise online behavior we spin up a tiny in-process HTTP server
// for this spec so the page can be served from http://127.0.0.1:<port>
// at arbitrary paths.
import { test, expect } from '@playwright/test';
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
const HTML_PATH = path.resolve('classifier/dist/classifier.html');
let server;
let baseUrl;
test.beforeAll(async () => {
const html = fs.readFileSync(HTML_PATH, 'utf8');
server = http.createServer((req, res) => {
// Serve the same classifier HTML at every path. The strip's
// detection logic uses location.pathname; the bytes don't have
// to vary.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
baseUrl = `http://127.0.0.1:${port}`;
});
test.afterAll(async () => {
if (server) await new Promise(resolve => server.close(resolve));
});
test.describe('shared/nav.js stage strip', () => {
test('does NOT render at the deployment root', async ({ page }) => {
await page.goto(`${baseUrl}/index.html`, { waitUntil: 'load' });
await page.waitForSelector('.app-header', { timeout: 5000 });
await expect(page.locator('.zddc-stage-strip')).toHaveCount(0);
});
test('renders for <project>/archive.html with archive active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
const strip = page.locator('.zddc-stage-strip');
await expect(strip).toHaveCount(1);
await expect(strip.locator('.zddc-stage-strip__project')).toHaveText('projA');
const stages = await strip.locator('.zddc-stage').allTextContents();
expect(stages).toEqual(['Archive', 'Working', 'Staging', 'Reviewing']);
const active = strip.locator('.zddc-stage--active');
await expect(active).toHaveCount(1);
await expect(active).toHaveText('Archive');
await expect(active).toHaveAttribute('aria-current', 'page');
});
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
await expect(active).toHaveText('Working');
});
test('stage links point to the canonical <project>/<stage>/ URLs', async ({ page }) => {
await page.goto(`${baseUrl}/projA/staging/`, { waitUntil: 'load' });
await page.waitForSelector('.zddc-stage-strip');
const links = await page.evaluate(() => {
const xs = document.querySelectorAll('.zddc-stage-strip .zddc-stage');
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
});
expect(links).toEqual([
{ text: 'Archive', href: '/projA/archive' },
{ text: 'Working', href: '/projA/working' },
{ text: 'Staging', href: '/projA/staging' },
{ text: 'Reviewing', href: '/projA/reviewing' },
]);
});
test('mounts immediately above the app-header', async ({ page }) => {
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
const prev = await page.evaluate(() => {
const h = document.querySelector('.app-header');
return h && h.previousElementSibling && h.previousElementSibling.className;
});
expect(prev).toContain('zddc-stage-strip');
});
});

View file

@ -26,7 +26,6 @@ concat_files \
"../shared/base.css" \ "../shared/base.css" \
"../shared/toast.css" \ "../shared/toast.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"css/base.css" \ "css/base.css" \
"css/layout.css" \ "css/layout.css" \
@ -55,7 +54,6 @@ concat_files \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/toast.js" \ "../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/app.js" \ "js/app.js" \

View file

@ -886,16 +886,25 @@ body.help-open .app-header {
/* Page-wide chrome when admin mode is active. The toggle alone is /* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue: easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire page (body) — peripheral- 1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position. vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin" 2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle. button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared. */ load and tears it down when elevation is cleared.
body.is-elevated {
outline: 3px solid var(--danger, #dc3545); Frame uses fixed positioning + pointer-events:none so it doesn't
outline-offset: -3px; reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
} }
.elevation-banner { .elevation-banner {
@ -950,63 +959,6 @@ body.is-elevated {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */ so it reads as clickable without altering the logo's visual weight. */
@ -1559,7 +1511,7 @@ body.is-elevated {
</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-18 15:55:46 · df19a63-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 21:36:23 · cff840e-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -2715,211 +2667,6 @@ body.is-elevated {
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to: