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

View file

@ -24,7 +24,6 @@ concat_files \
"../shared/fonts.css" \
"../shared/base.css" \
"../shared/toast.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \
@ -51,7 +50,6 @@ concat_files \
"../shared/zip-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.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/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
@ -45,7 +44,6 @@ concat_files \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \

View file

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

View file

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

View file

@ -48,16 +48,25 @@
/* Page-wide chrome when admin mode is active. The toggle alone is
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.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared. */
body.is-elevated {
outline: 3px solid var(--danger, #dc3545);
outline-offset: -3px;
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
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 {

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/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"../shared/context-menu.css" \
"css/table.css" \
@ -40,7 +39,6 @@ concat_files \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/help.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/toast.css" \
"../shared/elevation.css" \
"../shared/nav.css" \
"../shared/logo.css" \
"css/base.css" \
"css/layout.css" \
@ -55,7 +54,6 @@ concat_files \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/nav.js" \
"../shared/logo.js" \
"../shared/preview-lib.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
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.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared. */
body.is-elevated {
outline: 3px solid var(--danger, #dc3545);
outline-offset: -3px;
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
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 {
@ -950,63 +959,6 @@ body.is-elevated {
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
inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */
@ -1559,7 +1511,7 @@ body.is-elevated {
</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-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 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
// every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: