release: v0.0.17 lockstep
Some checks failed
Build + deploy releases / build-and-deploy (push) Failing after 11s
Build + deploy releases / notify-chart-prod (push) Has been skipped

This commit is contained in:
ZDDC 2026-05-19 10:46:42 -05:00
parent 480cb0e4a3
commit 69878532b0
7 changed files with 6573 additions and 1611 deletions

View file

@ -269,7 +269,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating Used on the "Use Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -331,6 +331,11 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -342,16 +347,35 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */ tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -359,6 +383,9 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -809,61 +836,127 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. /* shared/elevation.css — admin-elevation toggle in the tool header.
Sits as a sibling immediately under .app-header (mounted by JS). Renders only for users with admin scope (handled by elevation.js;
Rendered only in online mode when a project segment is in the URL. */ the placeholder is `.hidden` by default). When visible, sits left
of the theme button — sudo-style affordance for opting into admin
powers. */
.zddc-stage-strip { .elevation-toggle {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.3rem;
padding: 0.3rem 1rem; font-size: 0.78rem;
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); color: var(--text-muted);
user-select: none; user-select: none;
} cursor: pointer;
padding: 0.15rem 0.45rem;
.zddc-stage-strip__divider { border: 1px solid var(--border);
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius); border-radius: var(--radius);
transition: color 0.15s, background 0.15s; background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
} }
.zddc-stage:hover { .elevation-toggle:hover {
color: var(--text); background: var(--bg-hover);
background: var(--bg-secondary); border-color: var(--border-dark);
text-decoration: none;
} }
.zddc-stage--active { .elevation-toggle input[type="checkbox"] {
color: var(--primary); margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600; font-weight: 600;
} }
.zddc-stage--active:hover { /* Page-wide chrome when admin mode is active. The toggle alone is
color: var(--primary); easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: rgba(220, 53, 69, 0.95);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes elev-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
flex-shrink: 0;
}
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -2470,13 +2563,19 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span> <span class="build-timestamp">v0.0.17</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div> </div>
</header> </header>
@ -2680,7 +2779,7 @@ td[data-field="trackingNumber"] {
<div id="noDirectoryMessage" class="empty-state empty-state--overlay"> <div id="noDirectoryMessage" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered"> <div class="empty-state__inner empty-state__inner--centered">
<h2>Welcome to ZDDC Archive</h2> <h2>Welcome to ZDDC Archive</h2>
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p> <p>Click <strong>Use Local Directory</strong> to select an archive folder to browse.</p>
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p> <p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
<p><strong>How to navigate:</strong></p> <p><strong>How to navigate:</strong></p>
<ul class="welcome-list"> <ul class="welcome-list">
@ -2725,7 +2824,7 @@ td[data-field="trackingNumber"] {
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>When opened from a web server, the archive loads automatically from that server.</li> <li>When opened from a web server, the archive loads automatically from that server.</li>
<li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li> <li>Click <strong>Use Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
<li>The browser scans for grouping folders and transmittal folders automatically.</li> <li>The browser scans for grouping folders and transmittal folders automatically.</li>
<li>Select folders in the left panel to see their files in the main table.</li> <li>Select folders in the left panel to see their files in the main table.</li>
</ol> </ol>
@ -4048,6 +4147,7 @@ X.B(E,Y);return E}return J}())
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -4917,211 +5017,6 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -9835,7 +9730,7 @@ window.app.modules.filtering = {
// Apply UI differences based on source mode // Apply UI differences based on source mode
function applySourceModeUI() { function applySourceModeUI() {
// "Add Local Directory" button is always visible in both modes — // "Use Local Directory" button is always visible in both modes —
// in HTTP mode the user can augment the online archive with local directories. // in HTTP mode the user can augment the online archive with local directories.
} }
@ -10815,6 +10710,155 @@ window.app.modules.filtering = {
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View file

@ -269,7 +269,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating Used on the "Use Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -331,6 +331,11 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -342,16 +347,35 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */ tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -359,6 +383,9 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -809,61 +836,127 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. /* shared/elevation.css — admin-elevation toggle in the tool header.
Sits as a sibling immediately under .app-header (mounted by JS). Renders only for users with admin scope (handled by elevation.js;
Rendered only in online mode when a project segment is in the URL. */ the placeholder is `.hidden` by default). When visible, sits left
of the theme button — sudo-style affordance for opting into admin
powers. */
.zddc-stage-strip { .elevation-toggle {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.3rem;
padding: 0.3rem 1rem; font-size: 0.78rem;
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); color: var(--text-muted);
user-select: none; user-select: none;
} cursor: pointer;
padding: 0.15rem 0.45rem;
.zddc-stage-strip__divider { border: 1px solid var(--border);
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius); border-radius: var(--radius);
transition: color 0.15s, background 0.15s; background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
} }
.zddc-stage:hover { .elevation-toggle:hover {
color: var(--text); background: var(--bg-hover);
background: var(--bg-secondary); border-color: var(--border-dark);
text-decoration: none;
} }
.zddc-stage--active { .elevation-toggle input[type="checkbox"] {
color: var(--primary); margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600; font-weight: 600;
} }
.zddc-stage--active:hover { /* Page-wide chrome when admin mode is active. The toggle alone is
color: var(--primary); easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: rgba(220, 53, 69, 0.95);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes elev-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
flex-shrink: 0;
}
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -1681,13 +1774,19 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span> <span class="build-timestamp">v0.0.17</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div> </div>
</header> </header>
@ -1809,7 +1908,7 @@ body.help-open .app-header {
<li>Rename one file or all modified files at once</li> <li>Rename one file or all modified files at once</li>
</ul> </ul>
<p>Click <strong>Add Local Directory</strong> to begin.</p> <p>Click <strong>Use Local Directory</strong> to begin.</p>
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p> <p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
</div> </div>
@ -1828,7 +1927,7 @@ body.help-open .app-header {
<h3>Getting Started</h3> <h3>Getting Started</h3>
<ol> <ol>
<li>Click <strong>Add Local Directory</strong> to open a folder containing files to rename.</li> <li>Click <strong>Use Local Directory</strong> to open a folder containing files to rename.</li>
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li> <li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
<li>Edit cells in the spreadsheet to set the new filename components.</li> <li>Edit cells in the spreadsheet to set the new filename components.</li>
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li> <li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
@ -3146,6 +3245,7 @@ X.B(E,Y);return E}return J}())
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -3874,13 +3974,33 @@ X.B(E,Y);return E}return J}())
// Top-level helpers // Top-level helpers
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Strip a trailing tool .html (e.g. classifier.html) from a path // Resolve "the directory the tool was opened in" for the current
// to land on the "directory the tool was opened in". // page URL. Two URL shapes serve a tool:
//
// /…/<tool>.html — file URL; strip the trailing filename.
// /…/<dir>/ — trailing-slash directory URL; keep it.
// /…/<dir> — bare-directory URL served by the
// cascade's `default_tool` (e.g.
// archive/<party>/mdl serves the tables
// tool). Treat as the directory itself
// and append the missing slash.
//
// Discrimination is "does the last segment contain a dot?" — a dot
// is a reliable proxy for "looks like a file with an extension"
// since neither directory names nor default_tool paths contain
// them in this system.
function pathToDir(pathname) { function pathToDir(pathname) {
if (!pathname) return '/'; if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname; if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/'); var slash = pathname.lastIndexOf('/');
return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
if (lastSeg.indexOf('.') !== -1) {
// Has an extension → looks like a file URL → strip the
// filename to land on the parent directory.
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
}
// No extension → the URL IS the directory; just close it.
return pathname + '/';
} }
// Probe the server-mode root for the current page. Returns: // Probe the server-mode root for the current page. Returns:
@ -3960,9 +4080,14 @@ X.B(E,Y);return E}return J}())
// srcUrl points at the .md source on the server. fmt is one of // srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a // "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status). // friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) { async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt), var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
{ credentials: 'same-origin' }); var resp = await fetch(convertUrl, { credentials: 'same-origin' });
if (!resp.ok) { if (!resp.ok) {
var msg; var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.'; if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
@ -4163,211 +4288,6 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -9925,6 +9845,155 @@ X.B(E,Y);return E}return J}())
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
</script> </script>
</body> </body>
</html> </html>

View file

@ -269,7 +269,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating Used on the "Use Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -331,6 +331,11 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -342,16 +347,35 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */ tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -359,6 +383,9 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -809,61 +836,127 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. /* shared/elevation.css — admin-elevation toggle in the tool header.
Sits as a sibling immediately under .app-header (mounted by JS). Renders only for users with admin scope (handled by elevation.js;
Rendered only in online mode when a project segment is in the URL. */ the placeholder is `.hidden` by default). When visible, sits left
of the theme button — sudo-style affordance for opting into admin
powers. */
.zddc-stage-strip { .elevation-toggle {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.3rem;
padding: 0.3rem 1rem; font-size: 0.78rem;
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); color: var(--text-muted);
user-select: none; user-select: none;
} cursor: pointer;
padding: 0.15rem 0.45rem;
.zddc-stage-strip__divider { border: 1px solid var(--border);
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius); border-radius: var(--radius);
transition: color 0.15s, background 0.15s; background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
} }
.zddc-stage:hover { .elevation-toggle:hover {
color: var(--text); background: var(--bg-hover);
background: var(--bg-secondary); border-color: var(--border-dark);
text-decoration: none;
} }
.zddc-stage--active { .elevation-toggle input[type="checkbox"] {
color: var(--primary); margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600; font-weight: 600;
} }
.zddc-stage--active:hover { /* Page-wide chrome when admin mode is active. The toggle alone is
color: var(--primary); easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: rgba(220, 53, 69, 0.95);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes elev-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
flex-shrink: 0;
}
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -1424,10 +1517,16 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span> <span class="build-timestamp">v0.0.17</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div> </div>
@ -1632,6 +1731,7 @@ body {
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -2297,211 +2397,6 @@ body {
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -2632,6 +2527,155 @@ body {
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
(function() { (function() {
'use strict'; 'use strict';
// ZDDC landing page — project picker. // ZDDC landing page — project picker.
@ -2762,13 +2806,27 @@ body {
var data = JSON.parse(body); var data = JSON.parse(body);
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data); if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
allProjects = data.map(function(p) { // The root JSON is now a generic listing.FileInfo[] (same
return { // shape every other directory returns). Filter to
name: String(p.name || ''), // directories (projects are folders), strip the trailing
title: String(p.title || ''), // "/" the server adds to dir names, and pick up `title`
url: String(p.url || '') // (the per-project .zddc title:, populated by the
}; // server-side listing pipeline).
}).filter(function(p) { return p.name; }); allProjects = data
.filter(function (p) { return p && p.is_dir; })
.map(function (p) {
var raw = String(p.name || '').replace(/\/$/, '');
return {
name: raw,
title: String(p.title || ''),
url: String(p.url || '')
};
})
.filter(function (p) {
if (!p.name) return false;
var c = p.name.charAt(0);
return c !== '.' && c !== '_';
});
return true; return true;
} catch (e) { } catch (e) {
loadError = e.message || String(e); loadError = e.message || String(e);

View file

@ -273,7 +273,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating Used on the "Use Local Directory" button when a tool is operating
in server (online) mode — the local-dir affordance is still in server (online) mode — the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -335,6 +335,11 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -346,16 +351,35 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */ tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -363,6 +387,9 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo — sits left of the title in every tool's app-header. /* Brand logo — sits left of the title in every tool's app-header.
@ -813,61 +840,127 @@ body.help-open .app-header {
to { transform: translateX(100%); opacity: 0; } to { transform: translateX(100%); opacity: 0; }
} }
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. /* shared/elevation.css — admin-elevation toggle in the tool header.
Sits as a sibling immediately under .app-header (mounted by JS). Renders only for users with admin scope (handled by elevation.js;
Rendered only in online mode when a project segment is in the URL. */ the placeholder is `.hidden` by default). When visible, sits left
of the theme button — sudo-style affordance for opting into admin
powers. */
.zddc-stage-strip { .elevation-toggle {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.3rem;
padding: 0.3rem 1rem; font-size: 0.78rem;
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); color: var(--text-muted);
user-select: none; user-select: none;
} cursor: pointer;
padding: 0.15rem 0.45rem;
.zddc-stage-strip__divider { border: 1px solid var(--border);
margin-right: 0.35rem;
}
.zddc-stage {
color: var(--text-muted);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: var(--radius); border-radius: var(--radius);
transition: color 0.15s, background 0.15s; background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
} }
.zddc-stage:hover { .elevation-toggle:hover {
color: var(--text); background: var(--bg-hover);
background: var(--bg-secondary); border-color: var(--border-dark);
text-decoration: none;
} }
.zddc-stage--active { .elevation-toggle input[type="checkbox"] {
color: var(--primary); margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600; font-weight: 600;
} }
.zddc-stage--active:hover { /* Page-wide chrome when admin mode is active. The toggle alone is
color: var(--primary); easy to miss; these add an inescapable visual cue:
1. Thin red border around the entire viewport — peripheral-
vision reminder regardless of which tool / scroll position.
2. Sticky banner across the top with a one-click "Drop admin"
button so the user can disarm without hunting for the toggle.
Both rendered ONLY when the zddc-elevate cookie is set; the
shared/elevation.js init() syncs the body class on every page
load and tears it down when elevation is cleared.
Frame uses fixed positioning + pointer-events:none so it doesn't
reflow content or steal clicks. An inset outline on <body> was
tried first but overdrew content in tools whose root layout butts
right up to the viewport edge (browse split-pane, archive grid). */
body.is-elevated::after {
content: "";
position: fixed;
inset: 0;
border: 3px solid var(--danger, #dc3545);
pointer-events: none;
z-index: 9200; /* above banner (9100) so the frame paints on top */
}
.elevation-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: rgba(220, 53, 69, 0.95);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
position: sticky;
top: 0;
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.elevation-banner__dot {
width: 0.5rem;
height: 0.5rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: elev-pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes elev-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
}
.elevation-banner__msg {
flex: 1 1 auto;
}
.elevation-banner__off {
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.7);
color: #fff;
padding: 0.18rem 0.65rem;
border-radius: var(--radius, 4px);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
flex-shrink: 0;
}
.elevation-banner__off:hover {
background: rgba(255, 255, 255, 0.3);
} }
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
@ -2523,11 +2616,11 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span> <span class="build-timestamp">v0.0.17</span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;
other tools have "Add Local Directory" here instead) --> other tools have "Use Local Directory" here instead) -->
<div class="split-button" id="bottom-menu" hidden> <div class="split-button" id="bottom-menu" hidden>
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">&#x25BE;</button> <button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">&#x25BE;</button>
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button> <button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
@ -2535,6 +2628,12 @@ dialog.modal--narrow {
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button> <button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
</div> </div>
@ -4202,6 +4301,7 @@ X.B(E,Y);return E}return J}())
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC', 'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
'TBD',
]; ];
var STATUS_SET = {}; var STATUS_SET = {};
@ -4930,13 +5030,33 @@ X.B(E,Y);return E}return J}())
// Top-level helpers // Top-level helpers
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Strip a trailing tool .html (e.g. classifier.html) from a path // Resolve "the directory the tool was opened in" for the current
// to land on the "directory the tool was opened in". // page URL. Two URL shapes serve a tool:
//
// /…/<tool>.html — file URL; strip the trailing filename.
// /…/<dir>/ — trailing-slash directory URL; keep it.
// /…/<dir> — bare-directory URL served by the
// cascade's `default_tool` (e.g.
// archive/<party>/mdl serves the tables
// tool). Treat as the directory itself
// and append the missing slash.
//
// Discrimination is "does the last segment contain a dot?" — a dot
// is a reliable proxy for "looks like a file with an extension"
// since neither directory names nor default_tool paths contain
// them in this system.
function pathToDir(pathname) { function pathToDir(pathname) {
if (!pathname) return '/'; if (!pathname) return '/';
if (pathname.endsWith('/')) return pathname; if (pathname.endsWith('/')) return pathname;
var slash = pathname.lastIndexOf('/'); var slash = pathname.lastIndexOf('/');
return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
if (lastSeg.indexOf('.') !== -1) {
// Has an extension → looks like a file URL → strip the
// filename to land on the parent directory.
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
}
// No extension → the URL IS the directory; just close it.
return pathname + '/';
} }
// Probe the server-mode root for the current page. Returns: // Probe the server-mode root for the current page. Returns:
@ -5016,9 +5136,14 @@ X.B(E,Y);return E}return J}())
// srcUrl points at the .md source on the server. fmt is one of // srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a // "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status). // friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) { async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt), var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
{ credentials: 'same-origin' }); var resp = await fetch(convertUrl, { credentials: 'same-origin' });
if (!resp.ok) { if (!resp.ok) {
var msg; var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.'; if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
@ -5219,211 +5344,6 @@ X.B(E,Y);return E}return J}())
} }
})(); })();
// shared/nav.js — lateral navigation strip across the project's
// cascade-declared stages. Mounted as a sibling of <header class="app-
// header"> on DOMContentLoaded, hydrated from the project root's
// directory listing.
//
// Stage discovery is cascade-driven (Phase 4c): fetch the project
// root's JSON listing, filter to entries with `declared: true`
// (server stamps these from the .zddc cascade's paths: tree), and
// render in canonical workflow order with display_name overrides
// honored. An operator who edits the project's .zddc paths: to add
// a new declared child sees it in the strip; one who removes a
// canonical entry sees the strip drop it.
//
// When the fetch fails (offline / no-server / file://), the strip
// falls back to the hardcoded four-stage list so existing
// deployments don't lose chrome. Hardcoded labels in this file are
// the LAST resort — the cascade is the source of truth in normal
// operation.
//
// Stage URLs follow the slash/no-slash convention: no slash opens
// the stage's default tool. Operators on non-standard layouts can
// override by setting window.zddc.nav.disabled = true before
// DOMContentLoaded.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.nav) return; // already loaded
// Hardcoded fallback for offline / file:// / fetch-error contexts.
// Server-driven discovery (FETCH_STAGES below) is the normal path.
var FALLBACK_STAGES = [
{ name: 'archive', label: 'Archive' },
{ name: 'working', label: 'Working' },
{ name: 'staging', label: 'Staging' },
{ name: 'reviewing', label: 'Reviewing' },
];
// Canonical workflow order. Stages appearing in this list are
// rendered in this order; any extras the cascade declares are
// appended alphabetically.
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
function projectSegment(pathname) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) return null;
var first = parts[0];
if (first.indexOf('.') !== -1) return null;
return first;
}
function currentStage(pathname, stages) {
var parts = pathname.split('/').filter(Boolean);
if (parts.length < 2) return null;
var second = parts[1];
for (var i = 0; i < stages.length; i++) {
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
return stages[i].name;
}
}
if (second === 'archive.html') return 'archive';
return null;
}
function shouldRender() {
if (typeof location === 'undefined') return false;
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
if (window.zddc.nav && window.zddc.nav.disabled) return false;
return projectSegment(location.pathname) !== null;
}
function titleCase(s) {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
function sortByWorkflow(stages) {
return stages.slice().sort(function (a, b) {
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
if (ia >= 0 && ib >= 0) return ia - ib;
if (ia >= 0) return -1;
if (ib >= 0) return 1;
return a.name.localeCompare(b.name);
});
}
// Fetch the project root listing and extract declared stage
// entries. Returns [] on any error so callers fall back to the
// hardcoded list. Each stage entry is {name, label} — label
// honors the cascade's display: override when present.
async function fetchStagesFor(project) {
try {
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) return [];
var data = await resp.json();
if (!Array.isArray(data)) return [];
var stages = [];
for (var i = 0; i < data.length; i++) {
var e = data[i];
if (!e || !e.declared || !e.is_dir) continue;
var bare = (e.name || '').replace(/\/$/, '');
if (!bare) continue;
stages.push({
name: bare,
label: e.display_name || titleCase(bare),
});
}
return sortByWorkflow(stages);
} catch (_e) {
return [];
}
}
function buildStrip(project, active, stages) {
var nav = document.createElement('nav');
nav.className = 'zddc-stage-strip';
nav.setAttribute('aria-label', 'Project stage');
var label = document.createElement('span');
label.className = 'zddc-stage-strip__project';
label.textContent = project;
nav.appendChild(label);
var sep0 = document.createElement('span');
sep0.className = 'zddc-stage-strip__divider';
sep0.setAttribute('aria-hidden', 'true');
sep0.textContent = '/';
nav.appendChild(sep0);
for (var i = 0; i < stages.length; i++) {
var s = stages[i];
var a = document.createElement('a');
a.className = 'zddc-stage';
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
a.textContent = s.label;
if (s.name === active) {
a.classList.add('zddc-stage--active');
a.setAttribute('aria-current', 'page');
}
nav.appendChild(a);
if (i < stages.length - 1) {
var sep = document.createElement('span');
sep.className = 'zddc-stage-strip__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
nav.appendChild(sep);
}
}
return nav;
}
function mountWith(project, stages) {
var header = document.querySelector('.app-header');
if (!header) return;
if (header.previousElementSibling &&
header.previousElementSibling.classList &&
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
return; // already mounted
}
var active = currentStage(location.pathname, stages);
var strip = buildStrip(project, active, stages);
header.parentNode.insertBefore(strip, header);
}
async function mount() {
if (!shouldRender()) return;
var project = projectSegment(location.pathname);
if (!project) return;
// Render the hardcoded fallback immediately so the strip
// appears with no flicker, then upgrade to cascade-resolved
// stages once the fetch completes.
mountWith(project, FALLBACK_STAGES);
var fetched = await fetchStagesFor(project);
if (fetched.length === 0) return; // fetch failed → keep fallback
// Replace the strip with the cascade-driven one. Remove the
// existing strip first so mountWith re-mounts cleanly.
var existing = document.querySelector('.zddc-stage-strip');
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
mountWith(project, fetched);
}
window.zddc.nav = {
mount: mount,
_projectSegment: projectSegment,
_currentStage: currentStage,
_fallbackStages: FALLBACK_STAGES,
disabled: false,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount, { once: true });
} else {
mount();
}
})();
// shared/logo.js — turn the inert <svg class="app-header__logo"> on // shared/logo.js — turn the inert <svg class="app-header__logo"> on
// every tool's header into a clickable link. The destination is the // every tool's header into a clickable link. The destination is the
// nearest "home" the user can sensibly back out to: // nearest "home" the user can sensibly back out to:
@ -13349,6 +13269,155 @@ X.B(E,Y);return E}return J}())
} }
}()); }());
// shared/elevation.js — admin elevation toggle.
//
// Sudo-style model: admins behave as normal users by default; clicking
// the header toggle elevates the session so admin escape hatches (WORM
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
// State is carried in a `zddc-elevate=1` cookie that the server reads
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
// Only renders the toggle when /.profile/access reports the caller has
// some admin scope — a non-admin sees nothing, which keeps the chrome
// quiet for the common case. The toggle fades in once access loads so
// non-admins never even see the affordance flash.
//
// Click flow: set/clear the cookie, then reload the page so the server
// sees the new state on the next render. The reload is intentional —
// admin scaffolds in tool HTML are server-rendered for some tools, so
// a soft state flip on the client alone wouldn't reach those.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.elevation) return;
var COOKIE_NAME = 'zddc-elevate';
function isElevated() {
var parts = document.cookie.split(';');
for (var i = 0; i < parts.length; i++) {
var kv = parts[i].trim().split('=');
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
}
return false;
}
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten
// tab doesn't leave admin powers active indefinitely (sudo's
// 5-minute precedent informs the number — 30 minutes is a
// reasonable trade between annoyance and exposure).
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
} else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
}
}
async function fetchAccess() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
function render(host, elevated) {
host.classList.remove('hidden');
host.innerHTML =
'<input type="checkbox" id="elevation-checkbox"'
+ (elevated ? ' checked' : '') + '>'
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
+ 'Admin</label>';
var cb = host.querySelector('#elevation-checkbox');
cb.addEventListener('change', function () {
setElevated(cb.checked);
// Hard reload so server-rendered admin surfaces (profile
// page scaffolds, hidden-entry listings) catch up. URL
// and scroll state are preserved by the browser's normal
// back-forward cache rules.
window.location.reload();
});
}
// Page-wide affordances when elevation is active. The toggle alone
// is easy to miss — admin mode silently bypasses WORM and ACL
// restrictions, which produces surprising "I shouldn't have been
// able to do that" moments. A body class + a sticky banner with a
// one-click disable make the armed state unmistakable.
function applyArmedChrome(elevated) {
var b = document.body;
if (!b) return;
if (elevated) b.classList.add('is-elevated');
else b.classList.remove('is-elevated');
var banner = document.getElementById('elevation-banner');
if (elevated) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'elevation-banner';
banner.className = 'elevation-banner';
banner.setAttribute('role', 'alert');
banner.innerHTML =
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
+ '<span class="elevation-banner__msg">'
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
+ '</span>'
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
+ 'Drop admin'
+ '</button>';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () {
setElevated(false);
window.location.reload();
});
}
} else if (banner) {
banner.parentNode.removeChild(banner);
}
}
async function init() {
// Body chrome applies on every page load whether or not the
// header has a toggle slot — the banner needs to surface in
// tools / pages that don't host the toggle (e.g. iframed
// classifier inside browse's grid mode), so the user can't
// accidentally write through an elevated context elsewhere.
applyArmedChrome(isElevated());
var host = document.getElementById('elevation-toggle');
if (!host) return; // tool doesn't include the slot yet — no-op
var access = await fetchAccess();
if (!access) return; // anonymous / endpoint missing — no-op
// Surface ONLY for users who have admin authority somewhere.
// /.profile/access ships `can_elevate` as an elevation-
// INDEPENDENT signal — true for any user named in any admin
// list, regardless of current cookie state. The other flags
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
// authority and would be false for an un-elevated admin
// who hasn't toggled yet — so we can't gate on those.
if (!access.can_elevate) return;
render(host, isElevated());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
})();
(function (app) { (function (app) {
'use strict'; 'use strict';

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 archive=v0.0.17
transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 transmittal=v0.0.17
classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 classifier=v0.0.17
landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 landing=v0.0.17
form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 form=v0.0.17
tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 tables=v0.0.17
browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 browse=v0.0.17

View file

@ -1515,7 +1515,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:42:33 · 1721b4b-dirty</span></span> <span class="build-timestamp">v0.0.17</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -5350,7 +5350,23 @@ body.is-elevated::after {
// Success: clear drafts + invalid marks, capture new ETag. // Success: clear drafts + invalid marks, capture new ETag.
const newEtag = resp.headers.get('ETag'); const newEtag = resp.headers.get('ETag');
if (newEtag) row.etag = newEtag.replace(/"/g, ''); if (newEtag) row.etag = newEtag.replace(/"/g, '');
row.data = merged; // For record-typed writes the server echoes the stamped
// YAML (with server-managed audit fields) back as the
// response body — parse it and overwrite row.data so the
// table sees the same bytes that just landed on disk.
// Falls back to the local merge when the server didn't
// echo a body (non-record write or older server).
let serverData = null;
const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
if (ct.includes('yaml') && window.jsyaml) {
try {
const text = await resp.text();
if (text && text.trim()) serverData = window.jsyaml.load(text);
} catch (e) {
console.warn('[tables] server response YAML parse failed; using local merge', e);
}
}
row.data = serverData || merged;
delete app.state.drafts[rowId]; delete app.state.drafts[rowId];
clearCellInvalid(rowId); clearCellInvalid(rowId);
setRowState(rowId, ''); setRowState(rowId, '');
@ -6753,7 +6769,16 @@ body.is-elevated::after {
const help = (ui && ui['ui:help']) || ''; const help = (ui && ui['ui:help']) || '';
const placeholder = (ui && ui['ui:placeholder']) || ''; const placeholder = (ui && ui['ui:placeholder']) || '';
const widget = (ui && ui['ui:widget']) || ''; const widget = (ui && ui['ui:widget']) || '';
const readonly = !!(ui && ui['ui:readonly']); // readonly is honored from either source: an explicit UI override
// (ui:readonly: true) or the schema's readOnly field. The latter
// is set by the server when augmenting from cascade-locked
// records: entries and for audit fields declared readOnly in the
// *.form.yaml.
const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']);
// x-labels: { code → label } turns a bare enum into a labeled
// dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected
// by the server from the cascade's field_codes:codes map.
const labels = (schema && schema['x-labels']) || null;
const autofocus = !!(ui && ui['ui:autofocus']); const autofocus = !!(ui && ui['ui:autofocus']);
let input; let input;
@ -6799,17 +6824,22 @@ body.is-elevated::after {
if (widget === 'radio') { if (widget === 'radio') {
input = u.h('div', { className: 'form-field__radio-group' }); input = u.h('div', { className: 'form-field__radio-group' });
opts.forEach(function (opt, idx) { opts.forEach(function (opt, idx) {
const codeStr = String(opt);
const radioId = id + '-' + idx; const radioId = id + '-' + idx;
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) }); const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr });
if (value === opt) { if (value === opt) {
radio.checked = true; radio.checked = true;
} }
if (readonly) { if (readonly) {
radio.disabled = true; radio.disabled = true;
} }
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const lbl = u.h('label', { for: radioId }); const lbl = u.h('label', { for: radioId });
lbl.appendChild(radio); lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(' ' + String(opt))); lbl.appendChild(document.createTextNode(' ' + displayText));
input.appendChild(lbl); input.appendChild(lbl);
}); });
read = function () { read = function () {
@ -6822,7 +6852,12 @@ body.is-elevated::after {
input.appendChild(u.h('option', { value: '' }, '— select —')); input.appendChild(u.h('option', { value: '' }, '— select —'));
} }
opts.forEach(function (opt) { opts.forEach(function (opt) {
const o = u.h('option', { value: String(opt) }, String(opt)); const codeStr = String(opt);
let displayText = codeStr;
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
displayText = codeStr + ' — ' + labels[codeStr];
}
const o = u.h('option', { value: codeStr }, displayText);
if (value === opt) { if (value === opt) {
o.selected = true; o.selected = true;
} }
@ -6893,6 +6928,12 @@ body.is-elevated::after {
if (autofocus) { if (autofocus) {
input.autofocus = true; input.autofocus = true;
} }
// Schema-driven HTML pattern attribute. Used as a UX hint
// only — authoritative validation runs server-side via the
// cascade's field_codes.
if (schema.pattern && input.tagName === 'INPUT') {
input.pattern = schema.pattern;
}
read = function () { read = function () {
return input.value === '' ? undefined : input.value; return input.value === '' ? undefined : input.value;
}; };