@@ -4476,6 +4485,276 @@ X.B(E,Y);return E}return J}())
};
})(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
+ // "/". 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 "/" 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.
* 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
-// project stages (archive · working · staging · reviewing). Renders
-// only when:
-// 1. location.protocol is http: or https: (online — file:// has no
-// 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).
+// 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.
//
-// The strip is inserted as a sibling of
-// on DOMContentLoaded — no template changes required. Each tool just
-// needs ../shared/nav.{js,css} in its build.sh.
+// 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.
//
-// Stage URLs follow the canonical workflow folders documented at
-// zddc.varasys.io/reference.html#transmittal-workflow:
-// archive → /archive.html (archive tool, project-root mode)
-// working → /working/ (directory listing → mdedit auto-serves)
-// staging → /staging/ (directory listing → transmittal auto-serves)
-// reviewing → /reviewing/ (directory listing)
+// 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.
//
-// If a deployment doesn't have one of these folders the link will 404 —
-// the strip is convention-driven, not probed. Operators on non-standard
-// layouts can override by setting window.zddc.nav.disabled = true before
+// 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';
@@ -4667,33 +4946,37 @@ X.B(E,Y);return E}return J}())
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
- var STAGES = [
- { key: 'archive', label: 'Archive', target: 'archive.html' },
- { key: 'working', label: 'Working', target: 'working/' },
- { key: 'staging', label: 'Staging', target: 'staging/' },
- { key: 'reviewing', label: 'Reviewing', target: 'reviewing/' },
+ // 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];
- // 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;
return first;
}
- function currentStage(pathname) {
+ function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
- // /working/... | staging/... | reviewing/... | archive/...
- for (var i = 0; i < STAGES.length; i++) {
- if (second === STAGES[i].key) return STAGES[i].key;
+ for (var i = 0; i < stages.length; i++) {
+ if (second.toLowerCase() === stages[i].name.toLowerCase()) {
+ return stages[i].name;
+ }
}
- // /archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
@@ -4705,7 +4988,53 @@ X.B(E,Y);return E}return J}())
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');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
@@ -4721,19 +5050,19 @@ X.B(E,Y);return E}return J}())
sep0.textContent = '/';
nav.appendChild(sep0);
- for (var i = 0; i < STAGES.length; i++) {
- var s = STAGES[i];
+ 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.target;
+ a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
- if (s.key === active) {
+ if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
- if (i < STAGES.length - 1) {
+ if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
@@ -4745,34 +5074,44 @@ X.B(E,Y);return E}return J}())
return nav;
}
- function mount() {
- if (!shouldRender()) return;
+ function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
- // Don't double-mount if a tool's main.js calls us a second time.
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
- return;
+ return; // already mounted
}
- var project = projectSegment(location.pathname);
- var active = currentStage(location.pathname);
- 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.
+ var active = currentStage(location.pathname, stages);
+ var strip = buildStrip(project, active, stages);
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 = {
mount: mount,
- // Internals visible for unit tests; do not call from tools.
_projectSegment: projectSegment,
_currentStage: currentStage,
- _stages: STAGES,
- // Set to true before DOMContentLoaded to suppress mounting on
- // deployments where the canonical folder layout doesn't apply.
+ _fallbackStages: FALLBACK_STAGES,
disabled: false,
};
@@ -5469,6 +5808,15 @@ X.B(E,Y);return E}return J}())
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) {
const groups = {};
files.forEach(file => {
@@ -5520,6 +5868,7 @@ X.B(E,Y);return E}return J}())
window.app.modules.parser = {
isTransmittalFolder,
+ isTransmittalFolderZip,
groupFilesByTrackingNumber,
sortGroupedFiles,
};
@@ -5712,6 +6061,29 @@ X.B(E,Y);return E}return J}())
console.warn('Could not process directory ' + entry.name + ':', err);
}
} 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.
// actualPath records the real containing folder for grouping-folder-scoped filtering.
try {
@@ -6007,6 +6379,27 @@ X.B(E,Y);return E}return J}())
}
} else {
// 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/" 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) {
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
// 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 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 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)
+ ? ' (' + escapeHtml(name) + ')'
+ : '';
return '
'
+ ''
+ '
';
}).join('');
@@ -9490,6 +9894,9 @@ window.app.modules.filtering = {
// 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
// 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;
try {
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
@@ -9498,6 +9905,13 @@ window.app.modules.filtering = {
if (Array.isArray(serverProjects) && serverProjects.length > 0
&& serverProjects[0] && typeof serverProjects[0].name === 'string') {
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) {
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html
index 749fec7..a49d4c9 100644
--- a/zddc/internal/apps/embedded/browse.html
+++ b/zddc/internal/apps/embedded/browse.html
@@ -1252,6 +1252,57 @@ html, body {
font-weight: 500;
}
+/* ── Drag-drop upload overlay ─────────────────────────────────────────────── */
+/* Shown only while a drag is active over the page AND the current scope
+ accepts uploads. Pointer-events:none below dragover so the underlying
+ drop event still reaches the document handlers. */
+.upload-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ pointer-events: none;
+ background: rgba(42, 90, 138, 0.18);
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+.upload-overlay.is-active {
+ opacity: 1;
+}
+.upload-overlay__panel {
+ background: var(--bg);
+ border: 2px dashed var(--primary);
+ border-radius: var(--radius);
+ padding: 1.5rem 2.25rem;
+ text-align: center;
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
+ pointer-events: none;
+ color: var(--text);
+ max-width: 80vw;
+}
+.upload-overlay__icon {
+ font-size: 2.5rem;
+ line-height: 1;
+ color: var(--primary);
+}
+.upload-overlay__title {
+ font-family: var(--font-display);
+ font-size: 1.15rem;
+ font-weight: 600;
+ margin-top: 0.5rem;
+}
+.upload-overlay__path {
+ margin-top: 0.35rem;
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ word-break: break-all;
+}
+
/* Virtual rows: synthesized client-side for folders that aren't on
disk yet (canonical project folders). Rendered muted so the user
reads them as "available but empty" rather than ordinary entries.
@@ -1299,80 +1350,43 @@ html, body {
.status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
-/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar
- | body). The grid gives every cell a definite size, which Toast UI
- needs to compute its scroll regions correctly. A 4-px resizer sits
- between the editor and sidebar; JS updates grid-template-columns on
- drag. */
+/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT
+ (front matter top + TOC bottom), content on the RIGHT (informational
+ header above the Toast UI editor). The grid gives every cell a
+ definite size, which Toast UI needs to compute its scroll regions
+ correctly. */
.md-shell {
display: grid;
- grid-template-rows: auto 1fr;
- grid-template-columns: 1fr 260px; /* JS overrides on resize */
- grid-template-areas:
- "toolbar toolbar"
- "editor sidebar";
+ grid-template-rows: 1fr;
+ grid-template-columns: 280px 1fr; /* JS overrides on resize */
+ grid-template-areas: "sidebar content";
height: 100%;
min-height: 0;
background: var(--bg);
overflow: hidden;
}
-/* Toolbar spans both columns; subtle row above the editor. */
-.md-shell__toolbar {
- grid-area: toolbar;
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.35rem 0.75rem;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- font-size: 0.85rem;
-}
-.md-shell__dirty {
- color: var(--text-muted);
- font-size: 0.85rem;
- min-width: 5.5rem;
-}
-.md-shell__status {
- flex: 1;
- text-align: right;
- color: var(--text-muted);
- font-size: 0.85rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.md-shell__source {
- color: var(--text-muted);
- font-size: 0.75rem;
- font-style: italic;
- margin-left: 0.5rem;
- padding: 0.15rem 0.4rem;
- border-radius: var(--radius);
- background: var(--bg);
- border: 1px solid var(--border);
-}
-
-/* Editor host: a single grid cell with overflow:hidden so Toast UI's
- internal scrollers handle the content. */
-.md-shell__editor {
- grid-area: editor;
- min-width: 0;
+/* Sidebar (col 1): two stacked sections — Front matter (top, fixed
+ default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
+.md-shell__sidebar {
+ grid-area: sidebar;
+ display: grid;
+ grid-template-rows: 180px 1fr; /* JS overrides on resize */
min-height: 0;
overflow: hidden;
- /* Toast UI mounts a .toastui-editor-defaultUI element here; give
- it a definite height via height:100% in the JS. */
+ border-right: 1px solid var(--border);
+ background: var(--bg);
+ position: relative;
}
-/* Resizer sits on the grid border between editor (col 1) and sidebar
- (col 2). Positioned absolutely over the boundary so it doesn't take
- up a grid track itself. */
+/* Vertical sidebar/content resizer. Sits absolutely on the column
+ boundary so it doesn't occupy a grid track. */
.md-shell__resizer {
- grid-area: editor;
+ grid-area: sidebar;
align-self: stretch;
justify-self: end;
width: 6px;
- margin-right: -3px; /* center on the column boundary */
+ margin-right: -3px;
cursor: col-resize;
background: transparent;
z-index: 2;
@@ -1385,17 +1399,91 @@ html, body {
outline: none;
}
-/* Sidebar (right column): grid of two stacked sections — Outline
- (1fr) takes the bulk of the height, Front matter (auto, capped) is
- below. */
-.md-shell__sidebar {
- grid-area: sidebar;
+/* Horizontal resizer between front-matter and TOC inside the sidebar.
+ Spans both rows by placement, then absolutely positioned to overlay
+ the grid-row boundary. */
+.md-shell__fmresizer {
+ grid-column: 1;
+ grid-row: 1;
+ align-self: end;
+ justify-self: stretch;
+ height: 6px;
+ margin-bottom: -3px;
+ cursor: row-resize;
+ background: transparent;
+ z-index: 2;
+ transition: background 0.12s;
+}
+.md-shell__fmresizer:hover,
+.md-shell__fmresizer.is-dragging,
+.md-shell__fmresizer:focus-visible {
+ background: var(--primary);
+ outline: none;
+}
+
+/* Content (col 2): informational header above the Toast UI editor. */
+.md-shell__content {
+ grid-area: content;
display: grid;
- grid-template-rows: 1fr auto;
+ grid-template-rows: auto 1fr;
+ min-width: 0;
min-height: 0;
overflow: hidden;
- border-left: 1px solid var(--border);
+}
+
+/* Informational header above the editor: file name on the left, then
+ dirty marker, status, source hint, save button. Reads as a header
+ for the content panel — file metadata at a glance. */
+.md-shell__infohdr {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.75rem;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.85rem;
+}
+.md-shell__title {
+ flex: 1;
+ font-family: var(--font-display);
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+.md-shell__dirty {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ min-width: 5.5rem;
+ text-align: right;
+}
+.md-shell__status {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 14rem;
+}
+.md-shell__source {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-style: italic;
+ padding: 0.15rem 0.4rem;
+ border-radius: var(--radius);
background: var(--bg);
+ border: 1px solid var(--border);
+}
+
+/* Editor host: a single grid cell with overflow:hidden so Toast UI's
+ internal scrollers handle the content. */
+.md-shell__editor {
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
}
.md-side {
@@ -1404,10 +1492,8 @@ html, body {
min-height: 0;
overflow: hidden;
}
-.md-side--fm {
+.md-side--toc {
border-top: 1px solid var(--border);
- /* Front matter doesn't dominate — cap it so the outline keeps room. */
- max-height: 40%;
}
.md-side__header {
padding: 0.35rem 0.75rem;
@@ -1554,7 +1640,7 @@ html, body {
';
}
@@ -4901,28 +5359,60 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// table); the tree.setSort() method still works but only via
// programmatic callers — there's no UI for changing sort yet.
+ // True when this .zip node lives inside another zip, so its bytes
+ // can't be fetched as a standalone server resource: we read them
+ // through the containing handle (offline / nested) or by fetching
+ // the inner-zip member URL. In server mode a zip-inside-a-zip's URL
+ // contains ".zip/"; offline it has a handle that is itself a zip
+ // entry.
+ function zipNestedInsideZip(node) {
+ if (state.source === 'server') {
+ return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
+ }
+ return !!(node.handle && node.handle.isZipEntry);
+ }
+
+ // Open a .zip node as a directory handle (a ZipDirectoryHandle over
+ // a JSZip instance), cached on the node. Bytes come from a real
+ // FileSystemFileHandle / ZipFileHandle when present (offline, or a
+ // zip nested in a zip), else from a server URL — zddc-server returns
+ // the raw .zip for "<…>.zip" and the inner-zip bytes for
+ // ".zip/inner.zip".
+ async function zipDirHandle(node) {
+ if (node._zipDirHandle) return node._zipDirHandle;
+ await loader.ensureJSZip();
+ var zh;
+ if (node.handle) {
+ zh = await window.zddc.zip.fromFileHandle(node.handle);
+ } else if (node.url) {
+ var resp = await fetch(node.url, { credentials: 'same-origin' });
+ if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + node.url);
+ zh = await window.zddc.zip.fromBlob(await resp.arrayBuffer(), node.name);
+ } else {
+ throw new Error('cannot open zip ' + node.name + ' (no handle or URL)');
+ }
+ node._zipDirHandle = zh;
+ return zh;
+ }
+
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:
- // - regular folder → server JSON listing OR FS-API enumeration
- // - zip file → fetch+JSZip; entries become virtual children
- // - zip child dir → already-listed entries from the parent zip
- // (zips are enumerated whole, so child dirs
- // are pre-populated when the zip expands)
+ // - regular folder → server JSON listing OR FS-API entries
+ // - top-level .zip, server mode → the server's "<…>.zip/" virtual-
+ // directory listing (no whole-zip
+ // download — zddc-server extracts a
+ // member only when one is requested)
+ // - .zip otherwise (offline, or a zip nested in a zip)
+ // → open it with JSZip and enumerate
+ // it as a directory handle; members
+ // become ordinary dir/file nodes
async function loadChildren(node) {
if (node.loaded) return;
try {
- if (node.isZip) {
- await loadZipChildren(node);
- } else if (node._zipSyntheticDir) {
- // Synthetic dir node materialized when a zip's entry
- // list referenced "a/b/file" but had no "a/" entry.
- // Re-walk the owning zip's flat entry list with the
- // dir's full prefix.
- var owner = state.nodes.get(node.zipParentId);
- if (!owner || !owner.zipEntries) {
- throw new Error('zip parent not loaded');
- }
- setZipDirChildren(node, owner, node.zipPath + '/');
+ if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
+ setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
+ } else if (node.isZip) {
+ setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node)));
} else if (node.isDir) {
var raw;
if (state.source === 'server') {
@@ -4940,117 +5430,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
}
- // Fetch a zip's bytes, parse with JSZip, and materialize its
- // entries as a tree of virtual nodes. JSZip's entry list is flat
- // (full paths); we reconstruct the directory hierarchy on top.
- async function loadZipChildren(zipNode) {
- await loader.ensureJSZip();
- var arrayBuffer;
- if (state.source === 'server' && zipNode.url) {
- var resp = await fetch(zipNode.url);
- if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
- arrayBuffer = await resp.arrayBuffer();
- } else if (zipNode.handle) {
- // FS-API: top-level zip in a local folder.
- var f = await zipNode.handle.getFile();
- arrayBuffer = await f.arrayBuffer();
- } else if (zipNode.zipParentId != null) {
- // Nested zip inside another zip — read from parent JSZip.
- var parent = state.nodes.get(zipNode.zipParentId);
- if (!parent || !parent.zipFile) {
- throw new Error('parent zip not loaded');
- }
- arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
- } else {
- throw new Error('cannot fetch zip bytes (no source)');
- }
- var zip = await window.JSZip.loadAsync(arrayBuffer);
- zipNode.zipFile = zip;
-
- // Build a path → raw-entry map. Entry paths are
- // "dir/sub/file.ext" or "dir/" for directories. We slice
- // to immediate children of zipNode (i.e. zero slashes after
- // a leading prefix). For nested directories, we synthesize
- // folder nodes that lazy-expand to the next level via the
- // same raw-entry list — keep it on the zipNode for replay.
- zipNode.zipEntries = []; // for re-walk on expand of subdirs
- zip.forEach(function (relPath, entry) {
- zipNode.zipEntries.push({
- path: relPath.replace(/\/$/, ''),
- isDir: entry.dir,
- size: (entry._data && entry._data.uncompressedSize) || 0,
- modTime: entry.date instanceof Date ? entry.date : null,
- rawPath: relPath
- });
- });
-
- // Now seed top-level children of the zip itself.
- setZipDirChildren(zipNode, zipNode, '');
- }
-
- // Populate node's childIds with the entries directly under
- // pathPrefix (relative to the owning zip). Directory entries
- // become folder nodes whose own children are seeded on first
- // expand by this same function (recursively descending zipPath).
- function setZipDirChildren(node, zipOwner, pathPrefix) {
- var seen = new Map(); // immediate child name → raw entry
- zipOwner.zipEntries.forEach(function (e) {
- if (!e.path.startsWith(pathPrefix)) return;
- var rest = e.path.substring(pathPrefix.length);
- if (rest === '') return;
- // Take the FIRST segment of the remaining path
- var slash = rest.indexOf('/');
- var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
- var isImmediateFile = !e.isDir && slash === -1;
- var isImmediateDir = e.isDir && slash === -1;
- // For deeply-nested entries (rest contains a slash), we
- // surface only the first segment as a synthetic folder
- // entry. For immediate entries, we emit the entry as-is.
- if (isImmediateFile || isImmediateDir) {
- // Immediate entry — use the real metadata.
- seen.set(firstSeg, {
- name: firstSeg,
- isDir: e.isDir,
- size: e.size,
- modTime: e.modTime,
- ext: e.isDir ? '' : loader.splitExt(firstSeg),
- url: null,
- handle: null,
- zipPath: e.path,
- zipParentId: zipOwner.id
- });
- } else if (slash !== -1 && !seen.has(firstSeg)) {
- // Deeper entry, no explicit dir entry yet — synthesize.
- seen.set(firstSeg, {
- name: firstSeg,
- isDir: true,
- size: 0,
- modTime: null,
- ext: '',
- url: null,
- handle: null,
- zipPath: pathPrefix + firstSeg,
- zipParentId: zipOwner.id
- });
- }
- });
- // Drop existing children (re-load case)
- node.childIds.forEach(function (id) { state.nodes.delete(id); });
- node.childIds = [];
- seen.forEach(function (raw) {
- var n = newNode(raw, node.id, node.depth + 1);
- // Synthetic dir nodes inside zip don't have a dedicated
- // load path — they re-walk zipEntries on expand. Mark
- // them so the dispatcher knows.
- if (raw.isDir && !n.isZip) {
- n._zipSyntheticDir = true;
- }
- node.childIds.push(n.id);
- });
- sortNodes(node.childIds);
- node.loaded = true;
- }
-
// Toggle a folder's expanded state. Loads children on first expand.
// Treats "expandable" as either a real directory OR a zip file
// (zip files act like folders for tree purposes — the chevron
@@ -5211,13 +5590,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
async function getArrayBuffer(node) {
- if (node.zipParentId != null) {
- var owner = state.nodes.get(node.zipParentId);
- if (!owner || !owner.zipFile) {
- throw new Error('parent zip not loaded');
- }
- return await owner.zipFile.file(node.zipPath).async('arraybuffer');
- }
+ // A zip member node carries a ZipFileHandle in node.handle, so
+ // it falls through the same getFile() path as any local file.
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
@@ -5231,7 +5605,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
async function getBlobUrl(node) {
- if (state.source === 'server' && node.url && node.zipParentId == null) {
+ // Server-served files (including zip members at "<…>.zip/"
+ // URLs) load straight from the server — preserves Content-Type
+ // and lets relative links inside HTML resolve back to the server.
+ if (state.source === 'server' && node.url) {
return { url: node.url, fromServer: true };
}
var buf = await getArrayBuffer(node);
@@ -5523,9 +5900,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (!window.app || !window.app.modules) return;
- var TOC_MIN_WIDTH = 180;
- var TOC_MAX_WIDTH = 480;
- var TOC_DEFAULT_WIDTH = 260;
+ var SIDEBAR_MIN_WIDTH = 180;
+ var SIDEBAR_MAX_WIDTH = 480;
+ var SIDEBAR_DEFAULT_WIDTH = 280;
+ var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) {
return String(s).replace(/&/g, '&').replace(/.zip/" URL (405).
+ function isZipMemberNode(node) {
+ if (node.handle && node.handle.isZipEntry) return true;
+ if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true;
+ return false;
+ }
+
function canSave(node) {
- if (node.zipParentId != null) return false;
+ if (isZipMemberNode(node)) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
@@ -5797,20 +6185,78 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
// Wipe the container and install a single shell child. The
- // shell is a CSS Grid with two rows (toolbar | body) and two
- // columns (editor | sidebar). Setting these on a dedicated
- // child — rather than touching previewBody's class — keeps
- // the outer flex layout intact (previewBody itself is the
- // flex item that fills the preview pane).
+ // shell mirrors mdedit's layout: sidebar on the LEFT (front
+ // matter top, TOC bottom), content on the RIGHT (informational
+ // header above the Toast UI editor). CSS Grid keeps every
+ // cell sized definitely so Toast UI's scroll regions resolve
+ // correctly.
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'md-shell';
- shell.style.gridTemplateColumns = '1fr ' + lastTocWidth + 'px';
+ shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
container.appendChild(shell);
- // Toolbar (row 1, spans both columns).
- var toolbar = document.createElement('div');
- toolbar.className = 'md-shell__toolbar';
+ // ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
+ var sidebar = document.createElement('div');
+ sidebar.className = 'md-shell__sidebar';
+ sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
+ shell.appendChild(sidebar);
+
+ var fmSection = document.createElement('section');
+ fmSection.className = 'md-side md-side--fm';
+ var fmHeader = document.createElement('div');
+ fmHeader.className = 'md-side__header';
+ fmHeader.textContent = 'YAML front matter';
+ var fmBody = document.createElement('div');
+ fmBody.className = 'md-side__body md-fm__body';
+ fmSection.appendChild(fmHeader);
+ fmSection.appendChild(fmBody);
+ sidebar.appendChild(fmSection);
+
+ // Horizontal resizer between front-matter and TOC.
+ var fmResizer = document.createElement('div');
+ fmResizer.className = 'md-shell__fmresizer';
+ fmResizer.setAttribute('role', 'separator');
+ fmResizer.setAttribute('aria-orientation', 'horizontal');
+ fmResizer.setAttribute('aria-label', 'Resize front-matter pane');
+ fmResizer.tabIndex = 0;
+ sidebar.appendChild(fmResizer);
+
+ var tocSection = document.createElement('section');
+ tocSection.className = 'md-side md-side--toc';
+ var tocHeader = document.createElement('div');
+ tocHeader.className = 'md-side__header';
+ tocHeader.textContent = 'Outline';
+ var tocBody = document.createElement('div');
+ tocBody.className = 'md-side__body md-toc__body';
+ tocSection.appendChild(tocHeader);
+ tocSection.appendChild(tocBody);
+ sidebar.appendChild(tocSection);
+
+ // Vertical resizer between sidebar and content.
+ var resizer = document.createElement('div');
+ resizer.className = 'md-shell__resizer';
+ resizer.setAttribute('role', 'separator');
+ resizer.setAttribute('aria-orientation', 'vertical');
+ resizer.setAttribute('aria-label', 'Resize sidebar');
+ resizer.tabIndex = 0;
+ shell.appendChild(resizer);
+
+ // ── Content (col 2): informational header + editor ──────────────────
+ var content = document.createElement('div');
+ content.className = 'md-shell__content';
+ shell.appendChild(content);
+
+ // Informational header above the editor: file name + save +
+ // dirty indicator + status + source hint. Renamed from
+ // "toolbar" to read as a header, since it titles the content.
+ var infohdr = document.createElement('div');
+ infohdr.className = 'md-shell__infohdr';
+
+ var titleEl = document.createElement('span');
+ titleEl.className = 'md-shell__title';
+ titleEl.textContent = node.name;
+ titleEl.title = node.name;
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
@@ -5826,7 +6272,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
- if (node.zipParentId != null) {
+ if (isZipMemberNode(node)) {
sourceEl.textContent = 'read-only (zip)';
} else if (node.handle) {
sourceEl.textContent = 'local';
@@ -5834,52 +6280,17 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
sourceEl.textContent = 'server';
}
- toolbar.appendChild(saveBtn);
- toolbar.appendChild(dirtyEl);
- toolbar.appendChild(statusEl);
- toolbar.appendChild(sourceEl);
- shell.appendChild(toolbar);
+ infohdr.appendChild(titleEl);
+ infohdr.appendChild(dirtyEl);
+ infohdr.appendChild(statusEl);
+ infohdr.appendChild(sourceEl);
+ infohdr.appendChild(saveBtn);
+ content.appendChild(infohdr);
- // Editor host (row 2, col 1).
+ // Editor host.
var editorHost = document.createElement('div');
editorHost.className = 'md-shell__editor';
- shell.appendChild(editorHost);
-
- // Resizer between editor and sidebar (row 2, between cols).
- var resizer = document.createElement('div');
- resizer.className = 'md-shell__resizer';
- resizer.setAttribute('role', 'separator');
- resizer.setAttribute('aria-orientation', 'vertical');
- resizer.setAttribute('aria-label', 'Resize outline pane');
- resizer.tabIndex = 0;
- shell.appendChild(resizer);
-
- // Sidebar (row 2, col 2). Its own grid: outline (1fr) + front-matter (auto).
- var sidebar = document.createElement('div');
- sidebar.className = 'md-shell__sidebar';
- shell.appendChild(sidebar);
-
- var tocSection = document.createElement('section');
- tocSection.className = 'md-side md-side--toc';
- var tocHeader = document.createElement('div');
- tocHeader.className = 'md-side__header';
- tocHeader.textContent = 'Outline';
- var tocBody = document.createElement('div');
- tocBody.className = 'md-side__body md-toc__body';
- tocSection.appendChild(tocHeader);
- tocSection.appendChild(tocBody);
- sidebar.appendChild(tocSection);
-
- var fmSection = document.createElement('section');
- fmSection.className = 'md-side md-side--fm';
- var fmHeader = document.createElement('div');
- fmHeader.className = 'md-side__header';
- fmHeader.textContent = 'Front matter';
- var fmBody = document.createElement('div');
- fmBody.className = 'md-side__body md-fm__body';
- fmSection.appendChild(fmHeader);
- fmSection.appendChild(fmBody);
- sidebar.appendChild(fmSection);
+ content.appendChild(editorHost);
// Construct the editor. height: 100% works because editorHost
// is a grid cell with a definite size.
@@ -5919,10 +6330,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
- // ── Resizer ────────────────────────────────────────────────────────
- // Drag the resizer to grow/shrink the sidebar. Updates the
- // container's grid-template-columns so the editor + sidebar
- // both reflow cleanly.
+ // ── Sidebar/content resizer ─────────────────────────────────────────
+ // Sidebar is on the LEFT now. Dragging right grows the
+ // sidebar; left shrinks it.
(function () {
var dragging = false;
var startX = 0;
@@ -5930,12 +6340,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
function onMove(e) {
if (!dragging) return;
var dx = e.clientX - startX;
- // Dragging right shrinks the sidebar; left grows it.
- // (The sidebar is on the right; user expectation matches.)
- var w = startW - dx;
- w = Math.max(TOC_MIN_WIDTH, Math.min(TOC_MAX_WIDTH, w));
- lastTocWidth = w;
- shell.style.gridTemplateColumns = '1fr ' + w + 'px';
+ var w = startW + dx;
+ w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
+ lastSidebarWidth = w;
+ shell.style.gridTemplateColumns = w + 'px 1fr';
e.preventDefault();
}
function onUp() {
@@ -5953,15 +6361,58 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
- // Keyboard: ← / → adjust by 24px.
resizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
- var step = e.key === 'ArrowLeft' ? 24 : -24;
- var w = Math.max(TOC_MIN_WIDTH,
- Math.min(TOC_MAX_WIDTH, lastTocWidth + step));
- lastTocWidth = w;
- shell.style.gridTemplateColumns = '1fr ' + w + 'px';
+ var step = e.key === 'ArrowLeft' ? -24 : 24;
+ var w = Math.max(SIDEBAR_MIN_WIDTH,
+ Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
+ lastSidebarWidth = w;
+ shell.style.gridTemplateColumns = w + 'px 1fr';
+ });
+ })();
+
+ // ── Front-matter / TOC vertical resizer ─────────────────────────────
+ (function () {
+ var FM_MIN = 60;
+ var dragging = false;
+ var startY = 0;
+ var startH = 0;
+ function maxFmHeight() {
+ var sidebarRect = sidebar.getBoundingClientRect();
+ // Leave at least 120 px for the TOC body + headers.
+ return Math.max(FM_MIN, sidebarRect.height - 160);
+ }
+ function onMove(e) {
+ if (!dragging) return;
+ var dy = e.clientY - startY;
+ var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
+ lastFmHeight = h;
+ sidebar.style.gridTemplateRows = h + 'px 1fr';
+ e.preventDefault();
+ }
+ function onUp() {
+ dragging = false;
+ fmResizer.classList.remove('is-dragging');
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ }
+ fmResizer.addEventListener('mousedown', function (e) {
+ dragging = true;
+ fmResizer.classList.add('is-dragging');
+ startY = e.clientY;
+ startH = fmSection.getBoundingClientRect().height;
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ e.preventDefault();
+ });
+ fmResizer.addEventListener('keydown', function (e) {
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
+ e.preventDefault();
+ var step = e.key === 'ArrowUp' ? -24 : 24;
+ var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
+ lastFmHeight = h;
+ sidebar.style.gridTemplateRows = h + 'px 1fr';
});
})();
@@ -6016,84 +6467,42 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
};
})();
-// grid.js — "Grid mode" plugin for browse. Activated by the
-// view-mode toggle in the toolbar. Loads the standalone classifier
-// tool as an iframe scoped to the current directory; the user gets
-// classifier's full bulk-rename workflow without leaving browse.
+// grid.js — "Grid mode" plugin for browse. Loads the classifier tool
+// as an iframe scoped to the current directory so users get classifier's
+// full bulk-rename workflow without leaving browse.
//
-// This is a v1 — a future iteration could bundle classifier's
-// modules directly into browse for tighter integration (shared
-// state, no iframe chrome). For now the iframe is a clean separation
-// that preserves classifier's full feature set.
+// Availability: the cascade decides. Grid auto-activates wherever the
+// .zddc cascade resolves default_tool=classifier (defaults.zddc.yaml
+// declares this for archive//incoming/). Operators can extend
+// — e.g. setting default_tool=classifier on a custom dir activates
+// grid mode there too — without touching this code.
//
-// Iframe src resolution:
-// - server mode: /classifier.html. classifier is
-// auto-served at any working/staging/incoming subtree per
-// zddc-server's apps/availability.go. Outside those locations the
-// iframe will 404 — we surface a friendly message instead of an
-// opaque blank page.
-// - file:// or unknown: show a "switch to server mode for grid"
-// hint. classifier needs FS-API access; embedding it via file://
-// iframe is blocked by browser security.
+// Iframe src resolution: /classifier.html. Iframe
+// embedding only works in server mode; file:// pages don't get the
+// Grid toggle.
(function () {
'use strict';
var state = window.app.state;
var mounted = false;
- function escapeHtml(s) {
- return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
- }
-
function classifierAvailableHere() {
- // classifier auto-serves under any path containing a segment
- // named working / staging / incoming (case-insensitive).
- // browse mode-toggle should reflect that.
- var path = (window.location && window.location.pathname) || '';
- return /\/(working|staging|incoming)(\/|$)/i.test(path);
+ // state.scopeDefaultTool is set by the loader from the
+ // X-ZDDC-Default-Tool response header on every listing fetch.
+ // Grid mode is meaningful exactly where the cascade picks
+ // classifier as the default — no client-side path matching.
+ return state.scopeDefaultTool === 'classifier';
}
function activate() {
var host = document.getElementById('gridView');
if (!host) return;
-
if (mounted) return;
-
- host.innerHTML = '';
-
- if (state.source !== 'server') {
- host.innerHTML =
- '
'
- + '
Grid mode
'
- + '
The classifier (bulk ZDDC rename) workflow runs as an embedded'
- + ' iframe and requires the page be served by zddc-server.
'
- + '
If you opened this file directly (file://), open the standalone'
- + ' classifier.html tool instead — it provides the same'
- + ' workflow against a local folder you pick from the file system.
The classifier (bulk ZDDC rename) workflow auto-serves at'
- + ' working/, staging/, and'
- + ' incoming/ URLs. The current page'
- + ' (' + escapeHtml(window.location.pathname) + ') isn\'t'
- + ' inside any of those, so classifier isn\'t available here.
'
- + '
Navigate browse into a working/ or staging/ folder, then'
- + ' switch to Grid.
'
- + '
';
- return;
- }
+ if (state.source !== 'server' || !classifierAvailableHere()) return;
// Compute the iframe src: current page's directory + classifier.html.
var pathname = window.location.pathname || '/';
if (!pathname.endsWith('/')) {
- // Strip trailing /.html or similar — keep up to the last "/".
var lastSlash = pathname.lastIndexOf('/');
pathname = lastSlash >= 0 ? pathname.substring(0, lastSlash + 1) : '/';
}
@@ -6109,18 +6518,388 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
mounted = true;
}
+ // When the user navigates between scopes (client-side rescope on
+ // dblclick), the iframe needs to be reloaded for the new path.
+ // Callers reset before re-activating.
+ function reset() {
+ mounted = false;
+ var host = document.getElementById('gridView');
+ if (host) host.innerHTML = '';
+ }
+
window.app.modules.grid = {
activate: activate,
- // Hook the toggle button visibility / hint to the activation
- // predicate so users at non-classifier paths see the button
- // in a disabled state with explanation. Callers run this
- // after the initial directory is loaded.
+ reset: reset,
+ // Hook for events.js to show/hide the Grid toggle button.
availableHere: function () {
return state.source === 'server' && classifierAvailableHere();
}
};
})();
+// upload.js — drag-drop file upload into the current scope.
+//
+// Active only in server mode and only at paths where the cascade
+// declares drop_target: true (see zddc/internal/zddc/lookups.go
+// DropTargetAt + defaults.zddc.yaml). The loader captures the
+// X-ZDDC-Drop-Target response header on every directory listing
+// fetch and stamps state.scopeDropTarget; this module just reads it.
+//
+// At scopes where drop_target is false (or unset), the handlers
+// stay armed but ignore drops silently — no visible drop-zone
+// overlay. An operator can flip working/staging/incoming on or
+// extend the cascade to mark additional dirs as drop targets via
+// .zddc; the client follows automatically without code change.
+//
+// Wire model:
+// - dragenter on the document raises a counter; first-enter shows
+// the overlay.
+// - dragleave decrements; reaching zero hides the overlay.
+// - drop short-circuits: prevent default, PUT each file under the
+// current state.currentPath, surface per-file toast results,
+// refetch the listing on completion.
+//
+// The PUT uses fetch(``, method: 'PUT'). The
+// server's authorizeAction enforces write ACL on the parent; a 403
+// surfaces as an error toast and the rest of the batch proceeds.
+//
+// Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap
+// are rejected client-side with a clear toast — the server would
+// accept them in chunks but browse's v1 PUT is a single body, and
+// dropping a 4 GB CAD bundle into the browser tab as a Blob is a
+// poor experience. Operators with larger uploads should use a
+// dedicated client (zddc-cli or the cache/mirror downstream).
+(function () {
+ 'use strict';
+
+ if (!window.app || !window.app.modules) return;
+
+ var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
+ var state = window.app.state;
+ var enterCount = 0;
+ var overlayEl = null;
+
+ function ensureOverlay() {
+ if (overlayEl) return overlayEl;
+ overlayEl = document.createElement('div');
+ overlayEl.className = 'upload-overlay';
+ overlayEl.setAttribute('aria-hidden', 'true');
+ overlayEl.innerHTML =
+ '
@@ -2384,28 +2297,28 @@ body {
}
})();
-// shared/nav.js — lateral navigation strip across the four canonical
-// project stages (archive · working · staging · reviewing). Renders
-// only when:
-// 1. location.protocol is http: or https: (online — file:// has no
-// 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).
+// 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.
//
-// The strip is inserted as a sibling of
-// on DOMContentLoaded — no template changes required. Each tool just
-// needs ../shared/nav.{js,css} in its build.sh.
+// 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.
//
-// Stage URLs follow the canonical workflow folders documented at
-// zddc.varasys.io/reference.html#transmittal-workflow:
-// archive → /archive.html (archive tool, project-root mode)
-// working → /working/ (directory listing → mdedit auto-serves)
-// staging → /staging/ (directory listing → transmittal auto-serves)
-// reviewing → /reviewing/ (directory listing)
+// 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.
//
-// If a deployment doesn't have one of these folders the link will 404 —
-// the strip is convention-driven, not probed. Operators on non-standard
-// layouts can override by setting window.zddc.nav.disabled = true before
+// 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';
@@ -2413,33 +2326,37 @@ body {
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
- var STAGES = [
- { key: 'archive', label: 'Archive', target: 'archive.html' },
- { key: 'working', label: 'Working', target: 'working/' },
- { key: 'staging', label: 'Staging', target: 'staging/' },
- { key: 'reviewing', label: 'Reviewing', target: 'reviewing/' },
+ // 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];
- // 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;
return first;
}
- function currentStage(pathname) {
+ function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
- // /working/... | staging/... | reviewing/... | archive/...
- for (var i = 0; i < STAGES.length; i++) {
- if (second === STAGES[i].key) return STAGES[i].key;
+ for (var i = 0; i < stages.length; i++) {
+ if (second.toLowerCase() === stages[i].name.toLowerCase()) {
+ return stages[i].name;
+ }
}
- // /archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
@@ -2451,7 +2368,53 @@ body {
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');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
@@ -2467,19 +2430,19 @@ body {
sep0.textContent = '/';
nav.appendChild(sep0);
- for (var i = 0; i < STAGES.length; i++) {
- var s = STAGES[i];
+ 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.target;
+ a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
- if (s.key === active) {
+ if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
- if (i < STAGES.length - 1) {
+ if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
@@ -2491,34 +2454,44 @@ body {
return nav;
}
- function mount() {
- if (!shouldRender()) return;
+ function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
- // Don't double-mount if a tool's main.js calls us a second time.
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
- return;
+ return; // already mounted
}
- var project = projectSegment(location.pathname);
- var active = currentStage(location.pathname);
- 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.
+ var active = currentStage(location.pathname, stages);
+ var strip = buildStrip(project, active, stages);
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 = {
mount: mount,
- // Internals visible for unit tests; do not call from tools.
_projectSegment: projectSegment,
_currentStage: currentStage,
- _stages: STAGES,
- // Set to true before DOMContentLoaded to suppress mounting on
- // deployments where the canonical folder layout doesn't apply.
+ _fallbackStages: FALLBACK_STAGES,
disabled: false,
};
@@ -3365,7 +3338,11 @@ body {
function openSelectedMdl() {
var party = mdlSelect.value;
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);
}
mdlOpenBtn.addEventListener('click', openSelectedMdl);
diff --git a/zddc/internal/apps/embedded/mdedit.html b/zddc/internal/apps/embedded/mdedit.html
index 21b5ff6..7abae2b 100644
--- a/zddc/internal/apps/embedded/mdedit.html
+++ b/zddc/internal/apps/embedded/mdedit.html
@@ -2155,7 +2155,7 @@ body.help-open .app-header {
@@ -4428,28 +4428,28 @@ X.B(E,Y);return E}return J}())
}
})();
-// shared/nav.js — lateral navigation strip across the four canonical
-// project stages (archive · working · staging · reviewing). Renders
-// only when:
-// 1. location.protocol is http: or https: (online — file:// has no
-// 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).
+// 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.
//
-// The strip is inserted as a sibling of
-// on DOMContentLoaded — no template changes required. Each tool just
-// needs ../shared/nav.{js,css} in its build.sh.
+// 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.
//
-// Stage URLs follow the canonical workflow folders documented at
-// zddc.varasys.io/reference.html#transmittal-workflow:
-// archive → /archive.html (archive tool, project-root mode)
-// working → /working/ (directory listing → mdedit auto-serves)
-// staging → /staging/ (directory listing → transmittal auto-serves)
-// reviewing → /reviewing/ (directory listing)
+// 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.
//
-// If a deployment doesn't have one of these folders the link will 404 —
-// the strip is convention-driven, not probed. Operators on non-standard
-// layouts can override by setting window.zddc.nav.disabled = true before
+// 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';
@@ -4457,33 +4457,37 @@ X.B(E,Y);return E}return J}())
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
- var STAGES = [
- { key: 'archive', label: 'Archive', target: 'archive.html' },
- { key: 'working', label: 'Working', target: 'working/' },
- { key: 'staging', label: 'Staging', target: 'staging/' },
- { key: 'reviewing', label: 'Reviewing', target: 'reviewing/' },
+ // 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];
- // 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;
return first;
}
- function currentStage(pathname) {
+ function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
- // /working/... | staging/... | reviewing/... | archive/...
- for (var i = 0; i < STAGES.length; i++) {
- if (second === STAGES[i].key) return STAGES[i].key;
+ for (var i = 0; i < stages.length; i++) {
+ if (second.toLowerCase() === stages[i].name.toLowerCase()) {
+ return stages[i].name;
+ }
}
- // /archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
@@ -4495,7 +4499,53 @@ X.B(E,Y);return E}return J}())
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');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
@@ -4511,19 +4561,19 @@ X.B(E,Y);return E}return J}())
sep0.textContent = '/';
nav.appendChild(sep0);
- for (var i = 0; i < STAGES.length; i++) {
- var s = STAGES[i];
+ 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.target;
+ a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
- if (s.key === active) {
+ if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
- if (i < STAGES.length - 1) {
+ if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
@@ -4535,34 +4585,44 @@ X.B(E,Y);return E}return J}())
return nav;
}
- function mount() {
- if (!shouldRender()) return;
+ function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
- // Don't double-mount if a tool's main.js calls us a second time.
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
- return;
+ return; // already mounted
}
- var project = projectSegment(location.pathname);
- var active = currentStage(location.pathname);
- 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.
+ var active = currentStage(location.pathname, stages);
+ var strip = buildStrip(project, active, stages);
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 = {
mount: mount,
- // Internals visible for unit tests; do not call from tools.
_projectSegment: projectSegment,
_currentStage: currentStage,
- _stages: STAGES,
- // Set to true before DOMContentLoaded to suppress mounting on
- // deployments where the canonical folder layout doesn't apply.
+ _fallbackStages: FALLBACK_STAGES,
disabled: false,
};
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html
index 116f4e5..1d6c6ad 100644
--- a/zddc/internal/apps/embedded/transmittal.html
+++ b/zddc/internal/apps/embedded/transmittal.html
@@ -2523,7 +2523,7 @@ dialog.modal--narrow {