chore(embedded): cut v0.0.17-beta

This commit is contained in:
ZDDC 2026-05-12 13:25:44 -05:00
parent 141fef88fb
commit ba7e7a3fdd
8 changed files with 2214 additions and 855 deletions

View file

@ -2012,6 +2012,15 @@ input[type="checkbox"] {
cursor: pointer; cursor: pointer;
} }
/* Folder-name hint after the friendly title — shown only when the
project's .zddc declares a different `title:`. Muted so the title
reads first; the folder name is reference info. */
.preset-project-folder {
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
.preset-footer-actions { .preset-footer-actions {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@ -2461,7 +2470,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-11 · lens-mesa-chalk</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -4476,6 +4485,276 @@ X.B(E,Y);return E}return J}())
}; };
})(typeof window !== 'undefined' ? window : globalThis); })(typeof window !== 'undefined' ? window : globalThis);
// shared/zip-source.js — present the contents of a .zip as a tree of
// File System Access API handles, so tools written against
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner,
// browse's tree) can navigate into a zip with no special-casing.
//
// Mirrors shared/zddc-source.js's HttpDirectoryHandle / HttpFileHandle
// pair, but read-only and backed by a JSZip instance instead of HTTP.
// Online tools that talk to zddc-server should use the server's
// "<…>.zip/" virtual-directory route instead (no whole-zip download);
// this adapter is for the offline (file://) path where the zip bytes
// are already in hand, and for zips nested inside other zips.
//
// Requires window.JSZip (vendored at shared/vendor/jszip.min.js and
// concatenated by the tool's build.sh) — referenced lazily, only from
// fromBlob / fromFileHandle, so this module is harmless to include in
// a build that doesn't bundle JSZip (it just won't be usable there).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Minimal extension → media-type map so getFile() returns a Blob
// with a usable `type` (iframes/img tags need it to render inline).
var MIME = {
pdf: 'application/pdf',
html: 'text/html', htm: 'text/html',
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
json: 'application/json', xml: 'application/xml',
yaml: 'application/yaml', yml: 'application/yaml',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
zip: 'application/zip',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
};
function mimeFor(name) {
var dot = name.lastIndexOf('.');
if (dot < 0) return '';
return MIME[name.slice(dot + 1).toLowerCase()] || '';
}
function baseName(p) {
var s = p.replace(/\/+$/, '');
var i = s.lastIndexOf('/');
return i >= 0 ? s.slice(i + 1) : s;
}
// Reject zip entry names that aren't safe to surface: absolute
// paths, backslash separators, or anything that escapes via "..".
// Returns the cleaned forward-slash path (trailing "/" preserved
// for directory entries) or null.
function cleanEntryName(name) {
if (!name || name.indexOf('\\') !== -1 || name[0] === '/') return null;
var isDir = name.endsWith('/');
var parts = [];
var segs = name.split('/');
for (var i = 0; i < segs.length; i++) {
var s = segs[i];
if (s === '' || s === '.') continue;
if (s === '..') return null;
parts.push(s);
}
if (parts.length === 0) return null;
return parts.join('/') + (isDir ? '/' : '');
}
// -----------------------------------------------------------------
// ZipFileHandle — FileSystemFileHandle polyfill (read-only)
// -----------------------------------------------------------------
function ZipFileHandle(jszip, fullPath, size, modTime) {
this.kind = 'file';
this.name = baseName(fullPath);
this._zip = jszip;
this._path = fullPath; // path within the zip (no trailing /)
this._size = size || 0;
this._modTime = modTime || null;
}
ZipFileHandle.prototype.getFile = async function () {
var entry = this._zip.file(this._path);
if (!entry) {
var err = new Error('NotFoundError: ' + this._path);
err.name = 'NotFoundError';
throw err;
}
var buf = await entry.async('arraybuffer');
return new File([buf], this.name, {
type: mimeFor(this.name),
lastModified: this._modTime ? this._modTime.getTime() : Date.now()
});
};
ZipFileHandle.prototype.createWritable = async function () {
var err = new Error('Zip archives are read-only');
err.name = 'NoModificationAllowedError';
throw err;
};
ZipFileHandle.prototype.queryPermission = async function () { return 'granted'; };
ZipFileHandle.prototype.requestPermission = async function () { return 'granted'; };
ZipFileHandle.prototype.isZipEntry = true;
// -----------------------------------------------------------------
// ZipDirectoryHandle — FileSystemDirectoryHandle polyfill (read-only)
// -----------------------------------------------------------------
// jszip: the JSZip instance. prefix: "" for the zip root, else
// "<dir>/". name: the label for this level.
function ZipDirectoryHandle(jszip, prefix, name) {
this.kind = 'directory';
this._zip = jszip;
this._prefix = prefix || '';
this.name = name != null ? name : (this._prefix ? baseName(this._prefix) : '');
}
// Walk the flat entry list once, returning a Map of immediate child
// name → { isDir, size, modTime, fullPath }. Synthesises directory
// children that have no explicit "<dir>/" entry.
ZipDirectoryHandle.prototype._children = function () {
var prefix = this._prefix;
var seen = new Map();
var zip = this._zip;
Object.keys(zip.files).forEach(function (rawName) {
var clean = cleanEntryName(rawName);
if (clean === null) return;
var entryIsDir = clean.endsWith('/');
var bare = entryIsDir ? clean.slice(0, -1) : clean;
if (prefix) {
if (bare === prefix.slice(0, -1)) return; // the prefix dir itself
if (bare.indexOf(prefix) !== 0) return;
}
var rest = prefix ? bare.slice(prefix.length) : bare;
if (rest === '') return;
var slash = rest.indexOf('/');
var seg = slash === -1 ? rest : rest.slice(0, slash);
var nested = slash !== -1;
var existing = seen.get(seg);
if (nested) {
if (!existing) {
seen.set(seg, { isDir: true, size: 0, modTime: null, fullPath: prefix + seg });
} else {
existing.isDir = true;
}
} else if (entryIsDir) {
var entry = zip.files[rawName];
seen.set(seg, {
isDir: true, size: 0,
modTime: entry && entry.date instanceof Date ? entry.date : null,
fullPath: prefix + seg
});
} else {
if (!existing || existing.isDir !== false) {
var fent = zip.files[rawName];
seen.set(seg, {
isDir: false,
size: (fent && fent._data && fent._data.uncompressedSize) || 0,
modTime: fent && fent.date instanceof Date ? fent.date : null,
fullPath: prefix + seg
});
}
}
});
return seen;
};
ZipDirectoryHandle.prototype._handleFor = function (seg, info) {
if (info.isDir) {
return new ZipDirectoryHandle(this._zip, this._prefix + seg + '/', seg);
}
return new ZipFileHandle(this._zip, info.fullPath, info.size, info.modTime);
};
ZipDirectoryHandle.prototype.values = function () {
var self = this;
return (async function* () {
var children = self._children();
var names = Array.from(children.keys()).sort();
for (var i = 0; i < names.length; i++) {
yield self._handleFor(names[i], children.get(names[i]));
}
})();
};
ZipDirectoryHandle.prototype.entries = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield [step.value.name, step.value];
}
})();
};
ZipDirectoryHandle.prototype.keys = function () {
var iter = this.values();
return (async function* () {
for (;;) {
var step = await iter.next();
if (step.done) return;
yield step.value.name;
}
})();
};
ZipDirectoryHandle.prototype.getDirectoryHandle = async function (name) {
var children = this._children();
var info = children.get(name);
if (!info || !info.isDir) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
return this._handleFor(name, info);
};
ZipDirectoryHandle.prototype.getFileHandle = async function (name) {
var children = this._children();
var info = children.get(name);
if (!info || info.isDir) {
var err = new Error('NotFoundError: ' + name);
err.name = 'NotFoundError';
throw err;
}
return this._handleFor(name, info);
};
ZipDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
ZipDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
ZipDirectoryHandle.prototype.isZipEntry = true;
// -----------------------------------------------------------------
// Constructors
// -----------------------------------------------------------------
function requireJSZip() {
if (!window.JSZip) {
throw new Error('JSZip is not available — this build does not bundle it');
}
return window.JSZip;
}
// Build a ZipDirectoryHandle rooted at the top level of `blob`
// (an ArrayBuffer, Blob, Uint8Array, or anything JSZip.loadAsync
// accepts). `name` labels the root level (default: empty).
async function fromBlob(blob, name) {
var JSZip = requireJSZip();
var src = blob;
if (blob && typeof blob.arrayBuffer === 'function') {
src = await blob.arrayBuffer();
}
var zip = await JSZip.loadAsync(src);
return new ZipDirectoryHandle(zip, '', name || '');
}
// Build a ZipDirectoryHandle from a FileSystemFileHandle (or this
// adapter's own ZipFileHandle — so a zip nested inside a zip works
// by recursion). The handle's basename labels the root level.
async function fromFileHandle(fileHandle) {
var f = await fileHandle.getFile();
return fromBlob(f, fileHandle.name || (f && f.name) || '');
}
window.zddc.zip = {
ZipDirectoryHandle: ZipDirectoryHandle,
ZipFileHandle: ZipFileHandle,
fromBlob: fromBlob,
fromFileHandle: fromFileHandle,
// True for handles produced by this adapter (vs. real FS Access
// handles or the HTTP polyfill).
isZipHandle: function (h) { return !!(h && h.isZipEntry === true); }
};
})();
/** /**
* ZDDC shared theme toggle — light / dark / auto. * ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'. * Persists choice to localStorage under 'zddc-theme'.
@ -4638,28 +4917,28 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// 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 canonical workflow folders documented at // When the fetch fails (offline / no-server / file://), the strip
// zddc.varasys.io/reference.html#transmittal-workflow: // falls back to the hardcoded four-stage list so existing
// archive → <project>/archive.html (archive tool, project-root mode) // deployments don't lose chrome. Hardcoded labels in this file are
// working → <project>/working/ (directory listing → mdedit auto-serves) // the LAST resort — the cascade is the source of truth in normal
// staging → <project>/staging/ (directory listing → transmittal auto-serves) // operation.
// reviewing → <project>/reviewing/ (directory listing)
// //
// If a deployment doesn't have one of these folders the link will 404 — // Stage URLs follow the slash/no-slash convention: no slash opens
// the strip is convention-driven, not probed. Operators on non-standard // the stage's default tool. Operators on non-standard layouts can
// layouts can override by setting window.zddc.nav.disabled = true before // override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded. // DOMContentLoaded.
(function () { (function () {
'use strict'; 'use strict';
@ -4667,33 +4946,37 @@ X.B(E,Y);return E}return J}())
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.html' }, // 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;
} }
@ -4705,7 +4988,53 @@ X.B(E,Y);return E}return J}())
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');
@ -4721,19 +5050,19 @@ X.B(E,Y);return E}return J}())
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');
@ -4745,34 +5074,44 @@ X.B(E,Y);return E}return J}())
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,
}; };
@ -5469,6 +5808,15 @@ X.B(E,Y);return E}return J}())
return !!(parsed && parsed.valid); return !!(parsed && parsed.valid);
} }
// A .zip whose name (minus the .zip extension) parses as a
// transmittal-folder name is treated as that transmittal folder —
// its members are scanned the same as an uncompressed folder's
// files. (A plain `archive.zip` etc. is just a file.)
function isTransmittalFolderZip(name) {
var parts = zddc.splitExtension(name);
return parts.extension === 'zip' && isTransmittalFolder(parts.name);
}
function groupFilesByTrackingNumber(files) { function groupFilesByTrackingNumber(files) {
const groups = {}; const groups = {};
files.forEach(file => { files.forEach(file => {
@ -5520,6 +5868,7 @@ X.B(E,Y);return E}return J}())
window.app.modules.parser = { window.app.modules.parser = {
isTransmittalFolder, isTransmittalFolder,
isTransmittalFolderZip,
groupFilesByTrackingNumber, groupFilesByTrackingNumber,
sortGroupedFiles, sortGroupedFiles,
}; };
@ -5712,6 +6061,29 @@ X.B(E,Y);return E}return J}())
console.warn('Could not process directory ' + entry.name + ':', err); console.warn('Could not process directory ' + entry.name + ':', err);
} }
} else if (entry.kind === 'file') { } else if (entry.kind === 'file') {
// A zipped transmittal folder (e.g.
// "2025-05-12_DOC-001 (IFI) - Title.zip") is treated as
// that transmittal folder: open the zip in the browser
// and scan its members like an uncompressed folder's
// files. The .zip stays in the recorded path so it's
// unambiguous; the displayed name drops it.
if (window.app.modules.parser.isTransmittalFolderZip(entry.name)) {
const base = zddc.splitExtension(entry.name).name;
const zipPath = currentPath + '/' + entry.name;
try {
const zh = await window.zddc.zip.fromFileHandle(entry);
callbacks.onTransmittalFolder({
name: base,
path: zipPath,
displayPath: getDisplayPath(zipPath),
handle: zh
});
await scanLocalTransmittalFolder(zh, zipPath, 0, zipPath, callbacks);
} catch (zipErr) {
console.warn('Could not open zip transmittal ' + entry.name + ':', zipErr);
}
continue;
}
// File directly in a grouping folder — assign to the Outstanding virtual transmittal. // File directly in a grouping folder — assign to the Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering. // actualPath records the real containing folder for grouping-folder-scoped filtering.
try { try {
@ -6007,6 +6379,27 @@ X.B(E,Y);return E}return J}())
} }
} else { } else {
// It's a file // It's a file
// A zipped transmittal folder at the grouping level:
// zddc-server serves "<…>.zip/" as a virtual directory
// of the zip's members, so recurse into it like an
// uncompressed transmittal folder. Members come back
// with URLs like "<…>.zip/<member>" that the server
// extracts on demand — no whole-zip download.
if (transmittalPath === null && window.app.modules.parser.isTransmittalFolderZip(rawName)) {
const base = zddc.splitExtension(rawName).name;
const zipDirUrl = itemUrl + '/'; // itemUrl is the .zip file URL (no trailing slash)
callbacks.onTransmittalFolder({
name: base,
path: logicalPath,
displayPath: getDisplayPath(logicalPath),
handle: null,
url: zipDirUrl
});
subdirPromises.push(
scanHttpRecursive(zipDirUrl, rootUrl, depth + 1, logicalPath, callbacks)
);
continue;
}
if (transmittalPath === null) { if (transmittalPath === null) {
// File directly in a grouping folder — assign to Outstanding virtual transmittal. // File directly in a grouping folder — assign to Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering. // actualPath records the real containing folder for grouping-folder-scoped filtering.
@ -8455,13 +8848,24 @@ window.app.modules.filtering = {
var selected = new Set(window.app.visibleProjects || []); var selected = new Set(window.app.visibleProjects || []);
var known = getKnownProjects().slice().sort(); var known = getKnownProjects().slice().sort();
// Show the human-friendly title from each project's .zddc
// when present (captured during auto-detect into
// window.app.projectTitles), falling back to the folder name.
// The data-name attribute always carries the canonical folder
// name so URL state stays stable regardless of label.
var titles = window.app.projectTitles || {};
var projectsHtml = known.map(name => { var projectsHtml = known.map(name => {
var checked = selected.has(name) ? ' checked' : ''; var checked = selected.has(name) ? ' checked' : '';
var n = escapeHtml(name); var label = titles[name] || name;
var nAttr = escapeHtml(name);
var nLabel = escapeHtml(label);
var hint = (label !== name)
? ' <span class="preset-project-folder">(' + escapeHtml(name) + ')</span>'
: '';
return '<div class="preset-project-item">' return '<div class="preset-project-item">'
+ '<label class="preset-project-label">' + '<label class="preset-project-label">'
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>' + '<input type="checkbox" class="preset-checkbox" data-name="' + nAttr + '"' + checked + '>'
+ ' ' + n + ' ' + nLabel + hint
+ '</label>' + '</label>'
+ '</div>'; + '</div>';
}).join(''); }).join('');
@ -9490,6 +9894,9 @@ window.app.modules.filtering = {
// Fetch the server's ACL-filtered project list so we can drop any // Fetch the server's ACL-filtered project list so we can drop any
// listed names the user doesn't actually have access to (and so // listed names the user doesn't actually have access to (and so
// the empty-projects= "include everything" mode has a list to use). // the empty-projects= "include everything" mode has a list to use).
// ProjectInfo carries an optional `title` field sourced from each
// project's .zddc — capture it so the dropdown can show the
// human-friendly label instead of the folder name.
var serverNames = null; var serverNames = null;
try { try {
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } }); var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
@ -9498,6 +9905,13 @@ window.app.modules.filtering = {
if (Array.isArray(serverProjects) && serverProjects.length > 0 if (Array.isArray(serverProjects) && serverProjects.length > 0
&& serverProjects[0] && typeof serverProjects[0].name === 'string') { && serverProjects[0] && typeof serverProjects[0].name === 'string') {
serverNames = new Set(serverProjects.map(function(p) { return p.name; })); serverNames = new Set(serverProjects.map(function(p) { return p.name; }));
var titles = {};
serverProjects.forEach(function (p) {
if (p && typeof p.title === 'string' && p.title) {
titles[p.name] = p.title;
}
});
window.app.projectTitles = titles;
} }
} }
} catch (e) { } catch (e) {

File diff suppressed because it is too large Load diff

View file

@ -1681,7 +1681,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-11 · lens-mesa-chalk</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -4131,28 +4131,28 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// 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 canonical workflow folders documented at // When the fetch fails (offline / no-server / file://), the strip
// zddc.varasys.io/reference.html#transmittal-workflow: // falls back to the hardcoded four-stage list so existing
// archive → <project>/archive.html (archive tool, project-root mode) // deployments don't lose chrome. Hardcoded labels in this file are
// working → <project>/working/ (directory listing → mdedit auto-serves) // the LAST resort — the cascade is the source of truth in normal
// staging → <project>/staging/ (directory listing → transmittal auto-serves) // operation.
// reviewing → <project>/reviewing/ (directory listing)
// //
// If a deployment doesn't have one of these folders the link will 404 — // Stage URLs follow the slash/no-slash convention: no slash opens
// the strip is convention-driven, not probed. Operators on non-standard // the stage's default tool. Operators on non-standard layouts can
// layouts can override by setting window.zddc.nav.disabled = true before // override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded. // DOMContentLoaded.
(function () { (function () {
'use strict'; 'use strict';
@ -4160,33 +4160,37 @@ X.B(E,Y);return E}return J}())
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.html' }, // 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;
} }
@ -4198,7 +4202,53 @@ X.B(E,Y);return E}return J}())
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');
@ -4214,19 +4264,19 @@ X.B(E,Y);return E}return J}())
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');
@ -4238,34 +4288,44 @@ X.B(E,Y);return E}return J}())
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,
}; };

View file

@ -903,51 +903,6 @@ body {
padding: 0 16px 64px; padding: 0 16px 64px;
} }
/* Standalone-tool strip. Sits above the hero on the picker view. Each
link is a small card with the tool name (display serif) and a short
one-line hint (sans, muted). Hover lifts the card slightly to signal
it's clickable; the strip wraps onto multiple lines on narrow widths
rather than scrolling horizontally. */
.tool-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 24px;
padding: 0 4px;
}
.tool-strip__link {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 0.5rem 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-decoration: none;
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
min-width: 110px;
}
.tool-strip__link:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
text-decoration: none;
}
.tool-strip__name {
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
line-height: 1.15;
color: var(--text);
}
.tool-strip__hint {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.2;
}
/* Welcome / hero */ /* Welcome / hero */
.landing-hero { .landing-hero {
margin: 0 0 24px; margin: 0 0 24px;
@ -1469,7 +1424,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-11 · lens-mesa-chalk</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -1481,48 +1436,6 @@ body {
<main id="landingMain" class="landing-main"> <main id="landingMain" class="landing-main">
<!-- Picker mode (deployment root /). Project picker + groups. --> <!-- Picker mode (deployment root /). Project picker + groups. -->
<div id="pickerView"> <div id="pickerView">
<!-- Standalone-tool strip. Each link opens the latest stable
single-file build of one ZDDC tool from the canonical
release host (zddc.varasys.io/releases/). Useful for
"try this tool" / offline use without a project context. -->
<nav class="tool-strip" aria-label="ZDDC tools">
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/archive_stable.html"
title="Browse a project archive — filter by party, status, revision">
<span class="tool-strip__name">Archive</span>
<span class="tool-strip__hint">Browse &amp; filter</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/transmittal_stable.html"
title="Compose, validate, and publish transmittals">
<span class="tool-strip__name">Transmittal</span>
<span class="tool-strip__hint">Issue &amp; receive</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/classifier_stable.html"
title="Rename incoming files into ZDDC tracking numbers">
<span class="tool-strip__name">Classifier</span>
<span class="tool-strip__hint">Rename incoming</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/mdedit_stable.html"
title="Markdown editor with multi-file FS-Access workflow">
<span class="tool-strip__name">Markdown</span>
<span class="tool-strip__hint">Edit prose</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/browse_stable.html"
title="Unified file tree + per-file-type preview">
<span class="tool-strip__name">Browse</span>
<span class="tool-strip__hint">Files &amp; preview</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/form_stable.html"
title="Schema-driven form renderer for *.form.yaml files">
<span class="tool-strip__name">Form</span>
<span class="tool-strip__hint">Structured input</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/tables_stable.html"
title="Aggregate a directory of YAML rows into a sortable table">
<span class="tool-strip__name">Tables</span>
<span class="tool-strip__hint">YAML rollup</span>
</a>
</nav>
<!-- Welcome / hero --> <!-- Welcome / hero -->
<section class="landing-hero"> <section class="landing-hero">
<h1>Welcome to the ZDDC Archive</h1> <h1>Welcome to the ZDDC Archive</h1>
@ -2384,28 +2297,28 @@ body {
} }
})(); })();
// 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 canonical workflow folders documented at // When the fetch fails (offline / no-server / file://), the strip
// zddc.varasys.io/reference.html#transmittal-workflow: // falls back to the hardcoded four-stage list so existing
// archive → <project>/archive.html (archive tool, project-root mode) // deployments don't lose chrome. Hardcoded labels in this file are
// working → <project>/working/ (directory listing → mdedit auto-serves) // the LAST resort — the cascade is the source of truth in normal
// staging → <project>/staging/ (directory listing → transmittal auto-serves) // operation.
// reviewing → <project>/reviewing/ (directory listing)
// //
// If a deployment doesn't have one of these folders the link will 404 — // Stage URLs follow the slash/no-slash convention: no slash opens
// the strip is convention-driven, not probed. Operators on non-standard // the stage's default tool. Operators on non-standard layouts can
// layouts can override by setting window.zddc.nav.disabled = true before // override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded. // DOMContentLoaded.
(function () { (function () {
'use strict'; 'use strict';
@ -2413,33 +2326,37 @@ body {
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.html' }, // 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;
} }
@ -2451,7 +2368,53 @@ body {
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');
@ -2467,19 +2430,19 @@ body {
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');
@ -2491,34 +2454,44 @@ body {
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,
}; };
@ -3365,7 +3338,11 @@ body {
function openSelectedMdl() { function openSelectedMdl() {
var party = mdlSelect.value; var party = mdlSelect.value;
if (!party) return; if (!party) return;
var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl/'; // No trailing slash: per the convention, the no-slash form
// serves the tables tool with the MDL view. The slash form
// would serve browse, which is not what the user wants when
// they click "Open MDL".
var url = '/' + p + '/archive/' + encodeURIComponent(party) + '/mdl';
window.location.assign(url); window.location.assign(url);
} }
mdlOpenBtn.addEventListener('click', openSelectedMdl); mdlOpenBtn.addEventListener('click', openSelectedMdl);

View file

@ -2155,7 +2155,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-11 · lens-mesa-chalk</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh"></button>
@ -4428,28 +4428,28 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// 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 canonical workflow folders documented at // When the fetch fails (offline / no-server / file://), the strip
// zddc.varasys.io/reference.html#transmittal-workflow: // falls back to the hardcoded four-stage list so existing
// archive → <project>/archive.html (archive tool, project-root mode) // deployments don't lose chrome. Hardcoded labels in this file are
// working → <project>/working/ (directory listing → mdedit auto-serves) // the LAST resort — the cascade is the source of truth in normal
// staging → <project>/staging/ (directory listing → transmittal auto-serves) // operation.
// reviewing → <project>/reviewing/ (directory listing)
// //
// If a deployment doesn't have one of these folders the link will 404 — // Stage URLs follow the slash/no-slash convention: no slash opens
// the strip is convention-driven, not probed. Operators on non-standard // the stage's default tool. Operators on non-standard layouts can
// layouts can override by setting window.zddc.nav.disabled = true before // override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded. // DOMContentLoaded.
(function () { (function () {
'use strict'; 'use strict';
@ -4457,33 +4457,37 @@ X.B(E,Y);return E}return J}())
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.html' }, // 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;
} }
@ -4495,7 +4499,53 @@ X.B(E,Y);return E}return J}())
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');
@ -4511,19 +4561,19 @@ X.B(E,Y);return E}return J}())
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');
@ -4535,34 +4585,44 @@ X.B(E,Y);return E}return J}())
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,
}; };

View file

@ -2523,7 +2523,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-11 · lens-mesa-chalk</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;
@ -5187,28 +5187,28 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// 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 canonical workflow folders documented at // When the fetch fails (offline / no-server / file://), the strip
// zddc.varasys.io/reference.html#transmittal-workflow: // falls back to the hardcoded four-stage list so existing
// archive → <project>/archive.html (archive tool, project-root mode) // deployments don't lose chrome. Hardcoded labels in this file are
// working → <project>/working/ (directory listing → mdedit auto-serves) // the LAST resort — the cascade is the source of truth in normal
// staging → <project>/staging/ (directory listing → transmittal auto-serves) // operation.
// reviewing → <project>/reviewing/ (directory listing)
// //
// If a deployment doesn't have one of these folders the link will 404 — // Stage URLs follow the slash/no-slash convention: no slash opens
// the strip is convention-driven, not probed. Operators on non-standard // the stage's default tool. Operators on non-standard layouts can
// layouts can override by setting window.zddc.nav.disabled = true before // override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded. // DOMContentLoaded.
(function () { (function () {
'use strict'; 'use strict';
@ -5216,33 +5216,37 @@ X.B(E,Y);return E}return J}())
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.html' }, // 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;
} }
@ -5254,7 +5258,53 @@ X.B(E,Y);return E}return J}())
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');
@ -5270,19 +5320,19 @@ X.B(E,Y);return E}return J}())
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');
@ -5294,34 +5344,44 @@ X.B(E,Y);return E}return J}())
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,
}; };

View file

@ -1,9 +1,9 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk archive=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
transmittal=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk transmittal=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
classifier=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk classifier=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
mdedit=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk mdedit=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
landing=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk landing=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
form=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk form=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
tables=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk tables=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
browse=v0.0.17-beta · 2026-05-11 · lens-mesa-chalk browse=v0.0.17-beta · 2026-05-12 · candle-mast-pearl

View file

@ -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-12 15:29:24 · 54dff4d-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">