release: v0.0.17 lockstep
This commit is contained in:
parent
480cb0e4a3
commit
69878532b0
7 changed files with 6573 additions and 1611 deletions
|
|
@ -269,7 +269,7 @@ a:hover {
|
|||
}
|
||||
|
||||
/* 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
|
||||
available but visually quieter, since the typical user already
|
||||
has the directory loaded from the server. */
|
||||
|
|
@ -331,6 +331,11 @@ a:hover {
|
|||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
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
|
||||
|
|
@ -342,16 +347,35 @@ a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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'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 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
|
|
@ -359,6 +383,9 @@ a:hover {
|
|||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||||
Rendered only in online mode when a project segment is in the URL. */
|
||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
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 {
|
||||
display: flex;
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__project {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider,
|
||||
.zddc-stage-strip__sep {
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.zddc-stage {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
text-decoration: none;
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.zddc-stage--active {
|
||||
color: var(--primary);
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
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;
|
||||
}
|
||||
|
||||
.zddc-stage--active:hover {
|
||||
color: var(--primary);
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
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
|
||||
|
|
@ -2470,13 +2563,19 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||
<span class="build-timestamp">v0.0.17</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -2680,7 +2779,7 @@ td[data-field="trackingNumber"] {
|
|||
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
||||
<div class="empty-state__inner empty-state__inner--centered">
|
||||
<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><strong>How to navigate:</strong></p>
|
||||
<ul class="welcome-list">
|
||||
|
|
@ -2725,7 +2824,7 @@ td[data-field="trackingNumber"] {
|
|||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<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>Select folders in the left panel to see their files in the main table.</li>
|
||||
</ol>
|
||||
|
|
@ -4048,6 +4147,7 @@ X.B(E,Y);return E}return J}())
|
|||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||
'REC',
|
||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||
'TBD',
|
||||
];
|
||||
|
||||
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
|
||||
// every tool's header into a clickable link. The destination is the
|
||||
// nearest "home" the user can sensibly back out to:
|
||||
|
|
@ -9835,7 +9730,7 @@ window.app.modules.filtering = {
|
|||
|
||||
// Apply UI differences based on source mode
|
||||
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.
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -269,7 +269,7 @@ a:hover {
|
|||
}
|
||||
|
||||
/* 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
|
||||
available but visually quieter, since the typical user already
|
||||
has the directory loaded from the server. */
|
||||
|
|
@ -331,6 +331,11 @@ a:hover {
|
|||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
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
|
||||
|
|
@ -342,16 +347,35 @@ a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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'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 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
|
|
@ -359,6 +383,9 @@ a:hover {
|
|||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||||
Rendered only in online mode when a project segment is in the URL. */
|
||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
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 {
|
||||
display: flex;
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__project {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider,
|
||||
.zddc-stage-strip__sep {
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.zddc-stage {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
text-decoration: none;
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.zddc-stage--active {
|
||||
color: var(--primary);
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
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;
|
||||
}
|
||||
|
||||
.zddc-stage--active:hover {
|
||||
color: var(--primary);
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
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
|
||||
|
|
@ -1681,13 +1774,19 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||
<span class="build-timestamp">v0.0.17</span>
|
||||
</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>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -1809,7 +1908,7 @@ body.help-open .app-header {
|
|||
<li>Rename one file or all modified files at once</li>
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -1828,7 +1927,7 @@ body.help-open .app-header {
|
|||
|
||||
<h3>Getting Started</h3>
|
||||
<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>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>
|
||||
|
|
@ -3146,6 +3245,7 @@ X.B(E,Y);return E}return J}())
|
|||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||
'REC',
|
||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||
'TBD',
|
||||
];
|
||||
|
||||
var STATUS_SET = {};
|
||||
|
|
@ -3874,13 +3974,33 @@ X.B(E,Y);return E}return J}())
|
|||
// Top-level helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
||||
// to land on the "directory the tool was opened in".
|
||||
// Resolve "the directory the tool was opened in" for the current
|
||||
// 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) {
|
||||
if (!pathname) return '/';
|
||||
if (pathname.endsWith('/')) return pathname;
|
||||
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:
|
||||
|
|
@ -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
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// 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) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
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
|
||||
// every tool's header into a clickable link. The destination is the
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ a:hover {
|
|||
}
|
||||
|
||||
/* 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
|
||||
available but visually quieter, since the typical user already
|
||||
has the directory loaded from the server. */
|
||||
|
|
@ -331,6 +331,11 @@ a:hover {
|
|||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
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
|
||||
|
|
@ -342,16 +347,35 @@ a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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'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 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
|
|
@ -359,6 +383,9 @@ a:hover {
|
|||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||||
Rendered only in online mode when a project segment is in the URL. */
|
||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
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 {
|
||||
display: flex;
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__project {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider,
|
||||
.zddc-stage-strip__sep {
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.zddc-stage {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
text-decoration: none;
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.zddc-stage--active {
|
||||
color: var(--primary);
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
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;
|
||||
}
|
||||
|
||||
.zddc-stage--active:hover {
|
||||
color: var(--primary);
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
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
|
||||
|
|
@ -1424,10 +1517,16 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||
<span class="build-timestamp">v0.0.17</span>
|
||||
</div>
|
||||
</div>
|
||||
<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="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -1632,6 +1731,7 @@ body {
|
|||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||
'REC',
|
||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||
'TBD',
|
||||
];
|
||||
|
||||
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
|
||||
// every tool's header into a clickable link. The destination is the
|
||||
// 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() {
|
||||
'use strict';
|
||||
// ZDDC landing page — project picker.
|
||||
|
|
@ -2762,13 +2806,27 @@ body {
|
|||
|
||||
var data = JSON.parse(body);
|
||||
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
|
||||
allProjects = data.map(function(p) {
|
||||
return {
|
||||
name: String(p.name || ''),
|
||||
title: String(p.title || ''),
|
||||
url: String(p.url || '')
|
||||
};
|
||||
}).filter(function(p) { return p.name; });
|
||||
// The root JSON is now a generic listing.FileInfo[] (same
|
||||
// shape every other directory returns). Filter to
|
||||
// directories (projects are folders), strip the trailing
|
||||
// "/" the server adds to dir names, and pick up `title`
|
||||
// (the per-project .zddc title:, populated by the
|
||||
// server-side listing pipeline).
|
||||
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;
|
||||
} catch (e) {
|
||||
loadError = e.message || String(e);
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ a:hover {
|
|||
}
|
||||
|
||||
/* 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
|
||||
available but visually quieter, since the typical user already
|
||||
has the directory loaded from the server. */
|
||||
|
|
@ -335,6 +335,11 @@ a:hover {
|
|||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
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
|
||||
|
|
@ -346,16 +351,35 @@ a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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'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 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
|
|
@ -363,6 +387,9 @@ a:hover {
|
|||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
||||
Rendered only in online mode when a project segment is in the URL. */
|
||||
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||
Renders only for users with admin scope (handled by elevation.js;
|
||||
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 {
|
||||
display: flex;
|
||||
.elevation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 1rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__project {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider,
|
||||
.zddc-stage-strip__sep {
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.zddc-stage-strip__divider {
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.zddc-stage {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0.1rem 0.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
text-decoration: none;
|
||||
.elevation-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.zddc-stage--active {
|
||||
color: var(--primary);
|
||||
.elevation-toggle input[type="checkbox"] {
|
||||
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;
|
||||
}
|
||||
|
||||
.zddc-stage--active:hover {
|
||||
color: var(--primary);
|
||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||
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
|
||||
|
|
@ -2523,11 +2616,11 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
||||
<span class="build-timestamp">v0.0.17</span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
other tools have "Add Local Directory" here instead) -->
|
||||
other tools have "Use Local Directory" here instead) -->
|
||||
<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">▾</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 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="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||
</div>
|
||||
|
|
@ -4202,6 +4301,7 @@ X.B(E,Y);return E}return J}())
|
|||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||
'REC',
|
||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||
'TBD',
|
||||
];
|
||||
|
||||
var STATUS_SET = {};
|
||||
|
|
@ -4930,13 +5030,33 @@ X.B(E,Y);return E}return J}())
|
|||
// Top-level helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
||||
// to land on the "directory the tool was opened in".
|
||||
// Resolve "the directory the tool was opened in" for the current
|
||||
// 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) {
|
||||
if (!pathname) return '/';
|
||||
if (pathname.endsWith('/')) return pathname;
|
||||
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:
|
||||
|
|
@ -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
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// 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) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
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
|
||||
// every tool's header into a clickable link. The destination is the
|
||||
// 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) {
|
||||
'use strict';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# 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
|
||||
transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||
classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||
landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||
form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||
tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||
browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
||||
archive=v0.0.17
|
||||
transmittal=v0.0.17
|
||||
classifier=v0.0.17
|
||||
landing=v0.0.17
|
||||
form=v0.0.17
|
||||
tables=v0.0.17
|
||||
browse=v0.0.17
|
||||
|
|
|
|||
|
|
@ -1515,7 +1515,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:42:33 · 1721b4b-dirty</span></span>
|
||||
<span class="build-timestamp">v0.0.17</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -5350,7 +5350,23 @@ body.is-elevated::after {
|
|||
// Success: clear drafts + invalid marks, capture new ETag.
|
||||
const newEtag = resp.headers.get('ETag');
|
||||
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];
|
||||
clearCellInvalid(rowId);
|
||||
setRowState(rowId, '');
|
||||
|
|
@ -6753,7 +6769,16 @@ body.is-elevated::after {
|
|||
const help = (ui && ui['ui:help']) || '';
|
||||
const placeholder = (ui && ui['ui:placeholder']) || '';
|
||||
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']);
|
||||
|
||||
let input;
|
||||
|
|
@ -6799,17 +6824,22 @@ body.is-elevated::after {
|
|||
if (widget === 'radio') {
|
||||
input = u.h('div', { className: 'form-field__radio-group' });
|
||||
opts.forEach(function (opt, idx) {
|
||||
const codeStr = String(opt);
|
||||
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) {
|
||||
radio.checked = true;
|
||||
}
|
||||
if (readonly) {
|
||||
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 });
|
||||
lbl.appendChild(radio);
|
||||
lbl.appendChild(document.createTextNode(' ' + String(opt)));
|
||||
lbl.appendChild(document.createTextNode(' ' + displayText));
|
||||
input.appendChild(lbl);
|
||||
});
|
||||
read = function () {
|
||||
|
|
@ -6822,7 +6852,12 @@ body.is-elevated::after {
|
|||
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
||||
}
|
||||
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) {
|
||||
o.selected = true;
|
||||
}
|
||||
|
|
@ -6893,6 +6928,12 @@ body.is-elevated::after {
|
|||
if (autofocus) {
|
||||
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 () {
|
||||
return input.value === '' ? undefined : input.value;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue