// 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(); } })();