From 19566360a60f73b25cf44df2eaf64965bd8a051b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 18 May 2026 16:39:35 -0500 Subject: [PATCH] ui: fix admin-mode frame; drop project-stage strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- archive/build.sh | 2 - browse/build.sh | 2 - classifier/build.sh | 2 - form/build.sh | 2 - landing/build.sh | 2 - shared/elevation.css | 19 +- shared/nav.css | 56 ------ shared/nav.js | 204 --------------------- tables/build.sh | 2 - tests/nav.spec.js | 91 ---------- transmittal/build.sh | 2 - zddc/internal/handler/tables.html | 283 ++---------------------------- 12 files changed, 29 insertions(+), 638 deletions(-) mode change 100644 => 100755 archive/build.sh mode change 100644 => 100755 classifier/build.sh delete mode 100644 shared/nav.css delete mode 100644 shared/nav.js delete mode 100644 tests/nav.spec.js diff --git a/archive/build.sh b/archive/build.sh old mode 100644 new mode 100755 index 47d2733..721a515 --- a/archive/build.sh +++ b/archive/build.sh @@ -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" \ diff --git a/browse/build.sh b/browse/build.sh index 78c18bb..d976367 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -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" \ diff --git a/classifier/build.sh b/classifier/build.sh old mode 100644 new mode 100755 index 16eb7b1..20d066a --- a/classifier/build.sh +++ b/classifier/build.sh @@ -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" \ diff --git a/form/build.sh b/form/build.sh index 16fcdee..8f03208 100755 --- a/form/build.sh +++ b/form/build.sh @@ -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" \ diff --git a/landing/build.sh b/landing/build.sh index ecb984f..267d605 100755 --- a/landing/build.sh +++ b/landing/build.sh @@ -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" \ diff --git a/shared/elevation.css b/shared/elevation.css index 26c02c7..b88d12e 100644 --- a/shared/elevation.css +++ b/shared/elevation.css @@ -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 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 { diff --git a/shared/nav.css b/shared/nav.css deleted file mode 100644 index d7baab4..0000000 --- a/shared/nav.css +++ /dev/null @@ -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); -} diff --git a/shared/nav.js b/shared/nav.js deleted file mode 100644 index 745fe93..0000000 --- a/shared/nav.js +++ /dev/null @@ -1,204 +0,0 @@ -// shared/nav.js — lateral navigation strip across the project's -// cascade-declared stages. Mounted as a sibling of
on DOMContentLoaded, hydrated from the project root's -// directory listing. -// -// 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(); - } -})(); diff --git a/tables/build.sh b/tables/build.sh index 7d6b207..d733d81 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -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" \ diff --git a/tests/nav.spec.js b/tests/nav.spec.js deleted file mode 100644 index 4ae0fa3..0000000 --- a/tests/nav.spec.js +++ /dev/null @@ -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: -// 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 /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 /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 // 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'); - }); - -}); diff --git a/transmittal/build.sh b/transmittal/build.sh index 4487eb4..544134e 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -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" \ diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 44ad816..359c8ba 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -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 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 {
ZDDC Table - v0.0.17-alpha · 2026-05-18 15:55:46 · df19a63-dirty + v0.0.17-alpha · 2026-05-18 21:36:23 · cff840e-dirty
@@ -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
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