chore(embedded): cut v0.0.17-beta

Beta cut of the eight HTML tools into zddc/internal/apps/embedded/*
and the unified form/tables bundle into zddc/internal/handler/tables.html.
Each tool's on-page label changes from alpha → beta-stamped bytes;
no source changes beyond the build label itself.

The dev image (Dockerfile, devshell, ZDDC_REF=main) and the bitnest
test container both pick this up automatically — bitnest's path-unit
fired on the rebuild of zddc/dist/zddc-server-linux-amd64 and
restarted the container with the new embedded apps:

  embedded_apps=archive=v0.0.17-beta browse=v0.0.17-beta
                classifier=v0.0.17-beta form=v0.0.17-beta
                landing=v0.0.17-beta mdedit=v0.0.17-beta
                tables=v0.0.17-beta transmittal=v0.0.17-beta

Source-side commits since the previous beta:
  feat(landing): single-project click → <project>/archive.html
  feat(shared): non-blocking toast helper
  feat(shared): lateral project-stage strip
  feat(form): standalone empty-state welcome
  fix(tables): keepalive on beforeunload save path
  refactor(mdedit): drop window.* TOC globals
  refactor(archive): remove dead debounce
  style(transmittal): tokenize utility classes, drop !important block
  style: replace inline styles with CSS
  test(shared): zddc-source.js + toast + nav specs
  test(browse): smoke spec
  docs: tool counts + state pattern + polyfill gaps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 20:13:21 -05:00
parent b10468d4e3
commit 585e84f2f4
8 changed files with 1966 additions and 166 deletions

View file

@ -335,6 +335,11 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -525,6 +530,104 @@ body.help-open .app-header {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* Archive-specific base overrides
Reset, tokens, and font are provided by shared/base.css */
@ -1771,6 +1874,17 @@ input[type="checkbox"] {
cursor: default;
}
/* Variant: content packs at the start instead of distributing across the
column header. Used by the Revisions column where a leading "select all"
checkbox sits beside the column title. */
.th-content--start {
justify-content: flex-start;
}
.th-content--start .select-all-checkbox {
margin-right: 0.5rem;
}
.sortable .th-content {
cursor: pointer;
}
@ -2131,10 +2245,10 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<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-08 · falcon-alder-ginger</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
@ -2270,11 +2384,11 @@ td[data-field="trackingNumber"] {
<div class="resize-handle"></div>
</th>
<th class="resizable" data-field="revisions">
<div class="th-content" style="justify-content: flex-start;">
<input type="checkbox"
id="selectAllVisibleCheckbox"
title="Select/deselect all visible files"
style="margin-right: 0.5rem;">
<div class="th-content th-content--start">
<input type="checkbox"
id="selectAllVisibleCheckbox"
class="select-all-checkbox"
title="Select/deselect all visible files">
<span>Revisions</span>
</div>
<input type="text"
@ -3046,6 +3160,212 @@ https://github.com/nodeca/pako/blob/main/LICENSE
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// 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).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// 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
// DOMContentLoaded.
(function () {
'use strict';
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/' },
];
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) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
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.nextElementSibling &&
header.nextElementSibling.classList &&
header.nextElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
header.parentNode.insertBefore(strip, header.nextSibling);
}
// Expose for tests + opt-out.
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.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC — shared preview helpers
*
@ -7182,19 +7502,6 @@ window.app.modules.filtering = {
}
}
// Utility: Debounce function
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Multi-select handling for folder lists
function setupFolderMultiSelect() {
let lastSelectedGroupingIndex = -1;

View file

@ -335,6 +335,11 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -525,6 +530,104 @@ body.help-open .app-header {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* browse-specific layout on top of shared/base.css */
html, body {
@ -896,10 +999,10 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-08 · falcon-alder-ginger</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;"></button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
@ -1703,6 +1806,212 @@ https://github.com/nodeca/pako/blob/main/LICENSE
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// 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).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// 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
// DOMContentLoaded.
(function () {
'use strict';
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/' },
];
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) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
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.nextElementSibling &&
header.nextElementSibling.classList &&
header.nextElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
header.parentNode.insertBefore(strip, header.nextSibling);
}
// Expose for tests + opt-out.
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.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.

View file

@ -335,6 +335,11 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -525,6 +530,104 @@ body.help-open .app-header {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* Classifier-specific base overrides
Reset, tokens, buttons, and font are provided by shared/base.css */
@ -555,41 +658,8 @@ body.help-open .app-header {
cursor: pointer;
}
/* ── Toast notifications (classifier-only) ───────────────────────────────── */
/* shared/base.css intentionally omits toast CSS; only classifier uses toasts. */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
animation: zddc-toast-in 0.3s ease-out;
}
.toast-success { border-left: 4px solid var(--success); }
.toast-error { border-left: 4px solid var(--danger); }
.toast-info { border-left: 4px solid var(--info); }
.toast-warning { border-left: 4px solid var(--warning); }
.toast-fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Toast notifications come from shared/toast.css (.zddc-toast); the
classifier-local .toast block was promoted there. */
/* Classifier layout — tokens from shared/base.css */
@ -1394,7 +1464,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<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-08 · falcon-alder-ginger</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
<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>
@ -2559,6 +2629,212 @@ https://github.com/nodeca/pako/blob/main/LICENSE
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// 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).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// 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
// DOMContentLoaded.
(function () {
'use strict';
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/' },
];
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) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
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.nextElementSibling &&
header.nextElementSibling.classList &&
header.nextElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
header.parentNode.insertBefore(strip, header.nextSibling);
}
// Expose for tests + opt-out.
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.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC — shared preview helpers
*
@ -7863,26 +8139,19 @@ https://github.com/nodeca/pako/blob/main/LICENSE
'use strict';
/**
* Show toast notification
* Thin wrapper over the shared toast helper. Keeps the
* window.app.modules.excel.showToast call sites in classifier
* unchanged while delegating to the canonical implementation in
* shared/toast.js (window.zddc.toast).
*/
function showToast(message, type = 'info') {
// Remove existing toast
const existing = document.querySelector('.toast');
if (existing) {
existing.remove();
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(message, type);
} else {
// shared/toast.js missing from the build — log so the
// problem is visible without crashing the caller.
console.warn('[classifier] window.zddc.toast unavailable;', type, message);
}
// Create toast
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.add('toast-fade');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
/**

View file

@ -335,6 +335,11 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -525,6 +530,104 @@ body.help-open .app-header {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* Landing page layout */
body {
margin: 0;
@ -885,7 +988,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-08 · falcon-alder-ginger</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
</div>
<div class="header-right">
@ -1629,6 +1732,212 @@ body {
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// 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).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// 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
// DOMContentLoaded.
(function () {
'use strict';
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/' },
];
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) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
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.nextElementSibling &&
header.nextElementSibling.classList &&
header.nextElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
header.parentNode.insertBefore(strip, header.nextSibling);
}
// Expose for tests + opt-out.
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.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
@ -2107,8 +2416,22 @@ body {
function openArchiveWith(names) {
if (!names || names.length === 0) return;
var base = location.pathname.replace(/\/[^\/]*$/, '/');
var params = ['projects=' + names.map(encodeURIComponent).join(',')];
var v = new URLSearchParams(location.search).get('v');
if (names.length === 1) {
// Single project → canonical project-subtree URL so the user
// can edit the address bar to swap archive.html for
// working/, staging/, reviewing/, etc. zddc-server's
// availability.go auto-serves the right tool at each.
// Multi-project (the `else` branch) keeps the ?projects=
// form because there's no single subtree root.
var url = base + encodeURIComponent(names[0]) + '/archive.html';
if (v) url += '?v=' + encodeURIComponent(v);
navigate(url);
return;
}
var params = ['projects=' + names.map(encodeURIComponent).join(',')];
if (v) params.push('v=' + encodeURIComponent(v));
navigate(base + 'archive.html?' + params.join('&'));
}

View file

@ -555,6 +555,11 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -745,6 +750,104 @@ body.help-open .app-header {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* mdedit component styles — reset and tokens from shared/base.css */
/* Pane resizer */
@ -1151,6 +1254,14 @@ body.help-open .app-header {
gap: 0.5rem;
}
/* File-nav pane: initial width + minimum size. Runtime resizer (resizer.js)
overrides via inline style.width when the user drags; the min-width here
is a defensive backstop. */
#file-nav {
width: 450px;
min-width: 200px;
}
/* Toast UI Editor styles */
#markdown-editor {
display: block !important;
@ -1792,10 +1903,10 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<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-08 · falcon-alder-ginger</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
<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" style="font-size:1.1rem;"></button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
@ -1805,7 +1916,7 @@ body.help-open .app-header {
<main class="flex-1 overflow-hidden relative">
<div class="resizable-pane horizontal flex flex-row relative w-full h-full overflow-hidden" id="root-pane" data-pane-type="root">
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav" style="width: 450px; min-width: 200px;">
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav">
<div class="pane-header flex flex-col px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 select-none">
<div class="flex justify-between items-center w-full">
<span>Files</span>
@ -2757,6 +2868,212 @@ body.help-open .app-header {
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// 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).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// 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
// DOMContentLoaded.
(function () {
'use strict';
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/' },
];
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) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
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.nextElementSibling &&
header.nextElementSibling.classList &&
header.nextElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
header.parentNode.insertBefore(strip, header.nextSibling);
}
// Expose for tests + opt-out.
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.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC — shared preview helpers
*
@ -4441,9 +4758,9 @@ async function reloadFileFromDisk(filePath) {
}
}, 100);
if (editorInstance.tocContainer && window.updateToc) {
if (editorInstance.tocContainer) {
try {
window.updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
} catch (error) {
console.error('Error updating TOC during reload:', error);
}
@ -5837,9 +6154,9 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
tocDepthSelector.addEventListener('change', function () {
const depth = parseInt(this.value);
if (window.updateToc && editorInstance) {
if (editorInstance) {
const currentContent = editorInstance.getMarkdown();
window.updateToc(currentContent, tocContainer, editorInstance, depth);
updateToc(currentContent, tocContainer, editorInstance, depth);
}
});
}
@ -5886,16 +6203,16 @@ function initializeEditor(content, isMarkdown = true, filePath = '', fileName =
}
// Generate initial TOC
if (isMarkdown && window.updateToc && tocContainer) {
if (isMarkdown && tocContainer) {
try {
window.updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
} catch (error) {
console.error('Error generating TOC:', error);
}
const debouncedUpdateToc = debounce(() => {
const currentContent = editorInstance.getMarkdown();
window.updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
}, 300);
editorInstance.on('change', () => {
@ -6290,10 +6607,8 @@ function setActiveTocItem(tocContainer, headerText) {
}
}
// Export globally
window.updateToc = updateToc;
window.clearActiveTocItem = clearActiveTocItem;
window.setActiveTocItem = setActiveTocItem;
// Reachable at top-level scope to other concatenated mdedit JS files via the
// build's flat-IIFE-less module pattern; no window.* exports needed.
/**
* Pane resizing functionality
@ -6506,7 +6821,7 @@ function setupTocDepthSelector() {
const content = instance.editor.getMarkdown();
try {
window.updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
} catch (error) {
console.error('Error updating TOC depth:', error);
}

View file

@ -339,6 +339,11 @@ a:hover {
font-size: 1rem;
}
/* The refresh ⟳ glyph renders slightly smaller than ◐ / ? — bump to match. */
#refreshHeaderBtn {
font-size: 1.1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
@ -529,6 +534,104 @@ body.help-open .app-header {
color: var(--text-muted);
}
/* shared/toast.css — single-toast notification styles paired with
shared/toast.js. Uses BEM-ish .zddc-toast prefix to avoid collisions
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
.zddc-toast--warning { border-left: 4px solid var(--warning); }
.zddc-toast--fade {
animation: zddc-toast-out 0.3s ease-out forwards;
}
@keyframes zddc-toast-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes zddc-toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
Sits as a sibling immediately under .app-header (mounted by JS).
Rendered only in online mode when a project segment is in the URL. */
.zddc-stage-strip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
line-height: 1.3;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.zddc-stage-strip__project {
color: var(--text);
font-weight: 600;
margin-right: 0.15rem;
}
.zddc-stage-strip__divider,
.zddc-stage-strip__sep {
color: var(--text-muted);
user-select: none;
}
.zddc-stage-strip__divider {
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.zddc-stage:hover {
color: var(--text);
background: var(--bg-secondary);
text-decoration: none;
}
.zddc-stage--active {
color: var(--primary);
font-weight: 600;
}
.zddc-stage--active:hover {
color: var(--primary);
}
/* Placeholder for contenteditable elements */
[data-placeholder]:empty::before {
content: attr(data-placeholder);
@ -638,51 +741,15 @@ body.help-open .app-header {
border-bottom-color: #86efac;
}
/* Owner/Project names area and inline bg-white / bg-gray-50 utility classes */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .header-names {
background-color: var(--bg-secondary) !important;
border-color: var(--border) !important;
}
:root:not([data-theme="light"]) .text-gray-700 { color: var(--text-muted) !important; }
:root:not([data-theme="light"]) .bg-white { background-color: var(--bg) !important; }
:root:not([data-theme="light"]) .bg-gray-50 { background-color: var(--bg-secondary) !important; }
:root:not([data-theme="light"]) .bg-gray-100 { background-color: var(--bg-secondary) !important; }
:root:not([data-theme="light"]) .border-gray-100,
:root:not([data-theme="light"]) .border-gray-200,
:root:not([data-theme="light"]) .border-gray-300 { border-color: var(--border) !important; }
:root:not([data-theme="light"]) .text-gray-900 { color: var(--text) !important; }
}
[data-theme="dark"] .header-names {
background-color: var(--bg-secondary) !important;
border-color: var(--border) !important;
}
[data-theme="dark"] .text-gray-700 { color: var(--text-muted) !important; }
[data-theme="dark"] .bg-white { background-color: var(--bg) !important; }
[data-theme="dark"] .bg-gray-50 { background-color: var(--bg-secondary) !important; }
[data-theme="dark"] .bg-gray-100 { background-color: var(--bg-secondary) !important; }
[data-theme="dark"] .border-gray-100,
[data-theme="dark"] .border-gray-200,
[data-theme="dark"] .border-gray-300 { border-color: var(--border) !important; }
[data-theme="dark"] .text-gray-900 { color: var(--text) !important; }
/* Filter inputs in table column headers */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .table-filter-input {
background-color: var(--bg);
color: var(--text);
border-color: var(--border);
}
:root:not([data-theme="light"]) .table-header__caption { color: var(--text-muted); }
:root:not([data-theme="light"]) .focus\:bg-white:focus { background-color: var(--bg) !important; }
}
[data-theme="dark"] .table-filter-input {
background-color: var(--bg);
color: var(--text);
border-color: var(--border);
}
[data-theme="dark"] .table-header__caption { color: var(--text-muted); }
[data-theme="dark"] .focus\:bg-white:focus { background-color: var(--bg) !important; }
/* Note: dark-mode overrides for .bg-white / .bg-gray-* / .text-gray-*
/ .border-gray-* and .header-names used to live here as a 17-line
block of !important rules to fight hardcoded colors in
transmittal/css/utilities.css. The utility classes were tokenized
(var(--bg), var(--bg-secondary), var(--text), var(--text-muted),
var(--border)) so the cascade now does the right thing in both
themes without per-class overrides. .table-filter-input is unused
(no element references it; .column-filter from shared is used
instead) and was likewise dropped. */
}
/* Logo row: flex layout — logo | title | logo */
@ -1895,11 +1962,15 @@ dialog.modal--narrow {
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-\[12px\] { font-size: 12px; line-height: 1.4; }
.text-\[10px\] { font-size: 10px; line-height: 1.3; }
.text-gray-900 { color: #111827; }
.text-gray-700 { color: #374151; }
.text-gray-600 { color: #4b5563; }
.text-gray-500 { color: #6b7280; }
.text-gray-400 { color: #9ca3af; }
/* Gray-scale text classes are theme-encoding — they map to shared
tokens so dark mode swaps automatically without per-class overrides.
The named-color text classes (.text-blue-600/-green-600/-red-600)
carry semantic meaning (link / success / danger) and stay hardcoded. */
.text-gray-900 { color: var(--text); }
.text-gray-700 { color: var(--text-muted); }
.text-gray-600 { color: var(--text-muted); }
.text-gray-500 { color: var(--text-muted); }
.text-gray-400 { color: var(--text-muted); }
.text-blue-600 { color: #2563eb; }
.text-green-600 { color: #16a34a; }
.text-red-600 { color: #dc2626; }
@ -1908,20 +1979,20 @@ dialog.modal--narrow {
.leading-6 { line-height: 1.5rem; }
.leading-snug { line-height: 1.375rem; }
/* Backgrounds */
.bg-white { background-color: #ffffff; }
/* Backgrounds — gray-scale classes map to shared tokens. */
.bg-white { background-color: var(--bg); }
.bg-transparent { background-color: transparent; }
.bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; }
.bg-gray-50 { background-color: var(--bg-secondary); }
.bg-gray-100 { background-color: var(--bg-secondary); }
/* Borders */
.border { border: 1px solid #d1d5db; }
/* Borders — gray-scale border classes map to the shared token. */
.border { border: 1px solid var(--border); }
.border-0 { border: 0; }
.border-b { border-bottom: 1px solid #d1d5db; }
.border-t { border-top: 1px solid #d1d5db; }
.border-gray-300 { border-color: #d1d5db; }
.border-gray-200 { border-color: #e5e7eb; }
.border-gray-100 { border-color: #f3f4f6; }
.border-b { border-bottom: 1px solid var(--border); }
.border-t { border-top: 1px solid var(--border); }
.border-gray-300 { border-color: var(--border); }
.border-gray-200 { border-color: var(--border); }
.border-gray-100 { border-color: var(--border); }
.rounded-none { border-radius: 0; }
.rounded-sm { border-radius: 0.125rem; }
.rounded { border-radius: 0.25rem; }
@ -2008,14 +2079,14 @@ dialog.modal--narrow {
.border-red-500 { border-color: #ef4444 !important; }
/* Hover & focus states */
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
.hover\:bg-gray-50:hover { background-color: var(--bg-hover); }
.hover\:bg-gray-100:hover { background-color: var(--bg-hover); }
.hover\:underline:hover { text-decoration: underline; }
.focus\:outline-none:focus { outline: none; }
.focus\:border-blue-400:focus { border-color: #60a5fa; }
.focus\:ring-1:focus { box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35); }
.focus\:ring-blue-400:focus { box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.45); }
.focus\:bg-white:focus { background-color: #ffffff; }
.focus\:bg-white:focus { background-color: var(--bg); }
.disabled\:pointer-events-none:disabled { pointer-events: none; }
/* Table helpers */
@ -2192,7 +2263,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<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-08 · falcon-alder-ginger</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;
@ -3577,6 +3648,212 @@ https://github.com/nodeca/pako/blob/main/LICENSE
}
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
return el;
}
window.zddc.toast = toast;
})();
// 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).
//
// The strip is inserted as a sibling of <header class="app-header">
// on DOMContentLoaded — no template changes required. Each tool just
// needs ../shared/nav.{js,css} in its build.sh.
//
// Stage URLs follow the canonical workflow folders documented at
// zddc.varasys.io/reference.html#transmittal-workflow:
// archive → <project>/archive.html (archive tool, project-root mode)
// working → <project>/working/ (directory listing → mdedit auto-serves)
// staging → <project>/staging/ (directory listing → transmittal auto-serves)
// reviewing → <project>/reviewing/ (directory listing)
//
// 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
// DOMContentLoaded.
(function () {
'use strict';
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/' },
];
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) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
// <project>/working/... | staging/... | reviewing/... | archive/...
for (var i = 0; i < STAGES.length; i++) {
if (second === STAGES[i].key) return STAGES[i].key;
}
// <project>/archive.html → still the archive stage
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function buildStrip(project, active) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < STAGES.length; i++) {
var s = STAGES[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.target;
a.textContent = s.label;
if (s.key === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < STAGES.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mount() {
if (!shouldRender()) return;
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.nextElementSibling &&
header.nextElementSibling.classList &&
header.nextElementSibling.classList.contains('zddc-stage-strip')) {
return;
}
var project = projectSegment(location.pathname);
var active = currentStage(location.pathname);
var strip = buildStrip(project, active);
header.parentNode.insertBefore(strip, header.nextSibling);
}
// Expose for tests + opt-out.
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.
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
/**
* ZDDC — shared preview helpers
*

View file

@ -1,9 +1,9 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
transmittal=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
classifier=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
mdedit=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
landing=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
form=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
tables=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
browse=v0.0.17-beta · 2026-05-08 · falcon-alder-ginger
archive=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
transmittal=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
classifier=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
mdedit=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
landing=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
form=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
tables=v0.0.17-beta · 2026-05-10 · alder-cherry-reef
browse=v0.0.17-beta · 2026-05-10 · alder-cherry-reef

View file

@ -1070,7 +1070,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-10 00:48:10 · cc515b0-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-10 · alder-cherry-reef</span></span>
</div>
</div>
<div class="header-right">