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.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use Local Directory" button when a tool is operating
|
||||||
in server (online) mode — the local-dir affordance is still
|
in server (online) mode — the local-dir affordance is still
|
||||||
available but visually quieter, since the typical user already
|
available but visually quieter, since the typical user already
|
||||||
has the directory loaded from the server. */
|
has the directory loaded from the server. */
|
||||||
|
|
@ -331,6 +331,11 @@ a:hover {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Let the left / right groups wrap to a second row at narrow
|
||||||
|
viewports rather than overflowing the viewport edge. row-gap
|
||||||
|
gives a small breathing strip when wrapped. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left and right groups inside .app-header. Both flex-row so their
|
/* Left and right groups inside .app-header. Both flex-row so their
|
||||||
|
|
@ -342,16 +347,35 @@ a:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Allow the title to shrink (and ellipsize) before the action
|
||||||
|
buttons get pushed off-screen at narrow viewports. */
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title group (title + build label). Made shrinkable so narrow
|
||||||
|
viewports don't push the action buttons out of view; the title
|
||||||
|
itself ellipsizes via the rule below. */
|
||||||
|
.header-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool name inside the header. Renders in the display serif so the
|
/* Tool name inside the header. Renders in the display serif so the
|
||||||
tool's identity reads as a document title, not a UI label. */
|
tool's identity reads as a document title, not a UI label.
|
||||||
|
overflow + ellipsis on min-width:0 lets the title compress
|
||||||
|
gracefully when there's no room. */
|
||||||
.app-header__title {
|
.app-header__title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|
@ -359,6 +383,9 @@ a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand logo — sits left of the title in every tool's app-header.
|
/* Brand logo — sits left of the title in every tool's app-header.
|
||||||
|
|
@ -809,61 +836,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-toggle__label {
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||||
|
so the user can't miss that admin powers are currently live.
|
||||||
|
:has(:checked) lets us style the wrapper based on the inner
|
||||||
|
checkbox without JS. */
|
||||||
|
.elevation-toggle:has(input:checked) {
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
easy to miss; these add an inescapable visual cue:
|
||||||
|
1. Thin red border around the entire viewport — peripheral-
|
||||||
|
vision reminder regardless of which tool / scroll position.
|
||||||
|
2. Sticky banner across the top with a one-click "Drop admin"
|
||||||
|
button so the user can disarm without hunting for the toggle.
|
||||||
|
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||||
|
shared/elevation.js init() syncs the body class on every page
|
||||||
|
load and tears it down when elevation is cleared.
|
||||||
|
|
||||||
|
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||||
|
reflow content or steal clicks. An inset outline on <body> was
|
||||||
|
tried first but overdrew content in tools whose root layout butts
|
||||||
|
right up to the viewport edge (browse split-pane, archive grid). */
|
||||||
|
body.is-elevated::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid var(--danger, #dc3545);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||||
|
animation: elev-pulse 1.6s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__msg {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__off {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.18rem 0.65rem;
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -2470,13 +2563,19 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.17</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||||
|
when /.profile/access reports the user has admin
|
||||||
|
authority; stays empty + hidden for non-admins so
|
||||||
|
the chrome is quiet for the common case. -->
|
||||||
|
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||||
|
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||||
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -2680,7 +2779,7 @@ td[data-field="trackingNumber"] {
|
||||||
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
|
||||||
<div class="empty-state__inner empty-state__inner--centered">
|
<div class="empty-state__inner empty-state__inner--centered">
|
||||||
<h2>Welcome to ZDDC Archive</h2>
|
<h2>Welcome to ZDDC Archive</h2>
|
||||||
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
|
<p>Click <strong>Use Local Directory</strong> to select an archive folder to browse.</p>
|
||||||
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
|
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
|
||||||
<p><strong>How to navigate:</strong></p>
|
<p><strong>How to navigate:</strong></p>
|
||||||
<ul class="welcome-list">
|
<ul class="welcome-list">
|
||||||
|
|
@ -2725,7 +2824,7 @@ td[data-field="trackingNumber"] {
|
||||||
<h3>Getting Started</h3>
|
<h3>Getting Started</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>When opened from a web server, the archive loads automatically from that server.</li>
|
<li>When opened from a web server, the archive loads automatically from that server.</li>
|
||||||
<li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
|
<li>Click <strong>Use Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
|
||||||
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
|
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
|
||||||
<li>Select folders in the left panel to see their files in the main table.</li>
|
<li>Select folders in the left panel to see their files in the main table.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -4048,6 +4147,7 @@ X.B(E,Y);return E}return J}())
|
||||||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||||
'REC',
|
'REC',
|
||||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||||
|
'TBD',
|
||||||
];
|
];
|
||||||
|
|
||||||
var STATUS_SET = {};
|
var STATUS_SET = {};
|
||||||
|
|
@ -4917,211 +5017,6 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/nav.js — lateral navigation strip across the project's
|
|
||||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
||||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
||||||
// directory listing.
|
|
||||||
//
|
|
||||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
||||||
// root's JSON listing, filter to entries with `declared: true`
|
|
||||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
||||||
// render in canonical workflow order with display_name overrides
|
|
||||||
// honored. An operator who edits the project's .zddc paths: to add
|
|
||||||
// a new declared child sees it in the strip; one who removes a
|
|
||||||
// canonical entry sees the strip drop it.
|
|
||||||
//
|
|
||||||
// When the fetch fails (offline / no-server / file://), the strip
|
|
||||||
// falls back to the hardcoded four-stage list so existing
|
|
||||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
||||||
// the LAST resort — the cascade is the source of truth in normal
|
|
||||||
// operation.
|
|
||||||
//
|
|
||||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
||||||
// the stage's default tool. Operators on non-standard layouts can
|
|
||||||
// override by setting window.zddc.nav.disabled = true before
|
|
||||||
// DOMContentLoaded.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.nav) return; // already loaded
|
|
||||||
|
|
||||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
||||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
||||||
var FALLBACK_STAGES = [
|
|
||||||
{ name: 'archive', label: 'Archive' },
|
|
||||||
{ name: 'working', label: 'Working' },
|
|
||||||
{ name: 'staging', label: 'Staging' },
|
|
||||||
{ name: 'reviewing', label: 'Reviewing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Canonical workflow order. Stages appearing in this list are
|
|
||||||
// rendered in this order; any extras the cascade declares are
|
|
||||||
// appended alphabetically.
|
|
||||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
||||||
|
|
||||||
function projectSegment(pathname) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
var first = parts[0];
|
|
||||||
if (first.indexOf('.') !== -1) return null;
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStage(pathname, stages) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
var second = parts[1];
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
||||||
return stages[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (second === 'archive.html') return 'archive';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRender() {
|
|
||||||
if (typeof location === 'undefined') return false;
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
||||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
||||||
return projectSegment(location.pathname) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s) {
|
|
||||||
if (!s) return s;
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByWorkflow(stages) {
|
|
||||||
return stages.slice().sort(function (a, b) {
|
|
||||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
||||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
||||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
||||||
if (ia >= 0) return -1;
|
|
||||||
if (ib >= 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the project root listing and extract declared stage
|
|
||||||
// entries. Returns [] on any error so callers fall back to the
|
|
||||||
// hardcoded list. Each stage entry is {name, label} — label
|
|
||||||
// honors the cascade's display: override when present.
|
|
||||||
async function fetchStagesFor(project) {
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
var stages = [];
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var e = data[i];
|
|
||||||
if (!e || !e.declared || !e.is_dir) continue;
|
|
||||||
var bare = (e.name || '').replace(/\/$/, '');
|
|
||||||
if (!bare) continue;
|
|
||||||
stages.push({
|
|
||||||
name: bare,
|
|
||||||
label: e.display_name || titleCase(bare),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sortByWorkflow(stages);
|
|
||||||
} catch (_e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrip(project, active, stages) {
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.className = 'zddc-stage-strip';
|
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'zddc-stage-strip__project';
|
|
||||||
label.textContent = project;
|
|
||||||
nav.appendChild(label);
|
|
||||||
|
|
||||||
var sep0 = document.createElement('span');
|
|
||||||
sep0.className = 'zddc-stage-strip__divider';
|
|
||||||
sep0.setAttribute('aria-hidden', 'true');
|
|
||||||
sep0.textContent = '/';
|
|
||||||
nav.appendChild(sep0);
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
var s = stages[i];
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'zddc-stage';
|
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
||||||
a.textContent = s.label;
|
|
||||||
if (s.name === active) {
|
|
||||||
a.classList.add('zddc-stage--active');
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
nav.appendChild(a);
|
|
||||||
|
|
||||||
if (i < stages.length - 1) {
|
|
||||||
var sep = document.createElement('span');
|
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
|
||||||
sep.textContent = '·';
|
|
||||||
nav.appendChild(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountWith(project, stages) {
|
|
||||||
var header = document.querySelector('.app-header');
|
|
||||||
if (!header) return;
|
|
||||||
if (header.previousElementSibling &&
|
|
||||||
header.previousElementSibling.classList &&
|
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
||||||
return; // already mounted
|
|
||||||
}
|
|
||||||
var active = currentStage(location.pathname, stages);
|
|
||||||
var strip = buildStrip(project, active, stages);
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
|
||||||
if (!shouldRender()) return;
|
|
||||||
var project = projectSegment(location.pathname);
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Render the hardcoded fallback immediately so the strip
|
|
||||||
// appears with no flicker, then upgrade to cascade-resolved
|
|
||||||
// stages once the fetch completes.
|
|
||||||
mountWith(project, FALLBACK_STAGES);
|
|
||||||
|
|
||||||
var fetched = await fetchStagesFor(project);
|
|
||||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
||||||
|
|
||||||
// Replace the strip with the cascade-driven one. Remove the
|
|
||||||
// existing strip first so mountWith re-mounts cleanly.
|
|
||||||
var existing = document.querySelector('.zddc-stage-strip');
|
|
||||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
||||||
mountWith(project, fetched);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.nav = {
|
|
||||||
mount: mount,
|
|
||||||
_projectSegment: projectSegment,
|
|
||||||
_currentStage: currentStage,
|
|
||||||
_fallbackStages: FALLBACK_STAGES,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
||||||
} else {
|
|
||||||
mount();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||||||
// every tool's header into a clickable link. The destination is the
|
// every tool's header into a clickable link. The destination is the
|
||||||
// nearest "home" the user can sensibly back out to:
|
// nearest "home" the user can sensibly back out to:
|
||||||
|
|
@ -9835,7 +9730,7 @@ window.app.modules.filtering = {
|
||||||
|
|
||||||
// Apply UI differences based on source mode
|
// Apply UI differences based on source mode
|
||||||
function applySourceModeUI() {
|
function applySourceModeUI() {
|
||||||
// "Add Local Directory" button is always visible in both modes —
|
// "Use Local Directory" button is always visible in both modes —
|
||||||
// in HTTP mode the user can augment the online archive with local directories.
|
// in HTTP mode the user can augment the online archive with local directories.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10815,6 +10710,155 @@ window.app.modules.filtering = {
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
// shared/elevation.js — admin elevation toggle.
|
||||||
|
//
|
||||||
|
// Sudo-style model: admins behave as normal users by default; clicking
|
||||||
|
// the header toggle elevates the session so admin escape hatches (WORM
|
||||||
|
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
||||||
|
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
||||||
|
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||||
|
//
|
||||||
|
// Only renders the toggle when /.profile/access reports the caller has
|
||||||
|
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
||||||
|
// quiet for the common case. The toggle fades in once access loads so
|
||||||
|
// non-admins never even see the affordance flash.
|
||||||
|
//
|
||||||
|
// Click flow: set/clear the cookie, then reload the page so the server
|
||||||
|
// sees the new state on the next render. The reload is intentional —
|
||||||
|
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
||||||
|
// a soft state flip on the client alone wouldn't reach those.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
if (window.zddc.elevation) return;
|
||||||
|
|
||||||
|
var COOKIE_NAME = 'zddc-elevate';
|
||||||
|
|
||||||
|
function isElevated() {
|
||||||
|
var parts = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var kv = parts[i].trim().split('=');
|
||||||
|
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElevated(on) {
|
||||||
|
if (on) {
|
||||||
|
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||||||
|
// shapes. Max-Age caps the elevation window so a forgotten
|
||||||
|
// tab doesn't leave admin powers active indefinitely (sudo's
|
||||||
|
// 5-minute precedent informs the number — 30 minutes is a
|
||||||
|
// reasonable trade between annoyance and exposure).
|
||||||
|
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
||||||
|
} else {
|
||||||
|
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccess() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/.profile/access', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return await resp.json();
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(host, elevated) {
|
||||||
|
host.classList.remove('hidden');
|
||||||
|
host.innerHTML =
|
||||||
|
'<input type="checkbox" id="elevation-checkbox"'
|
||||||
|
+ (elevated ? ' checked' : '') + '>'
|
||||||
|
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
||||||
|
+ 'Admin</label>';
|
||||||
|
var cb = host.querySelector('#elevation-checkbox');
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
setElevated(cb.checked);
|
||||||
|
// Hard reload so server-rendered admin surfaces (profile
|
||||||
|
// page scaffolds, hidden-entry listings) catch up. URL
|
||||||
|
// and scroll state are preserved by the browser's normal
|
||||||
|
// back-forward cache rules.
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-wide affordances when elevation is active. The toggle alone
|
||||||
|
// is easy to miss — admin mode silently bypasses WORM and ACL
|
||||||
|
// restrictions, which produces surprising "I shouldn't have been
|
||||||
|
// able to do that" moments. A body class + a sticky banner with a
|
||||||
|
// one-click disable make the armed state unmistakable.
|
||||||
|
function applyArmedChrome(elevated) {
|
||||||
|
var b = document.body;
|
||||||
|
if (!b) return;
|
||||||
|
if (elevated) b.classList.add('is-elevated');
|
||||||
|
else b.classList.remove('is-elevated');
|
||||||
|
|
||||||
|
var banner = document.getElementById('elevation-banner');
|
||||||
|
if (elevated) {
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'elevation-banner';
|
||||||
|
banner.className = 'elevation-banner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.innerHTML =
|
||||||
|
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
||||||
|
+ '<span class="elevation-banner__msg">'
|
||||||
|
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
||||||
|
+ '</span>'
|
||||||
|
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
||||||
|
+ 'Drop admin'
|
||||||
|
+ '</button>';
|
||||||
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
|
var off = banner.querySelector('#elevation-banner-off');
|
||||||
|
if (off) off.addEventListener('click', function () {
|
||||||
|
setElevated(false);
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (banner) {
|
||||||
|
banner.parentNode.removeChild(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Body chrome applies on every page load whether or not the
|
||||||
|
// header has a toggle slot — the banner needs to surface in
|
||||||
|
// tools / pages that don't host the toggle (e.g. iframed
|
||||||
|
// classifier inside browse's grid mode), so the user can't
|
||||||
|
// accidentally write through an elevated context elsewhere.
|
||||||
|
applyArmedChrome(isElevated());
|
||||||
|
|
||||||
|
var host = document.getElementById('elevation-toggle');
|
||||||
|
if (!host) return; // tool doesn't include the slot yet — no-op
|
||||||
|
var access = await fetchAccess();
|
||||||
|
if (!access) return; // anonymous / endpoint missing — no-op
|
||||||
|
// Surface ONLY for users who have admin authority somewhere.
|
||||||
|
// /.profile/access ships `can_elevate` as an elevation-
|
||||||
|
// INDEPENDENT signal — true for any user named in any admin
|
||||||
|
// list, regardless of current cookie state. The other flags
|
||||||
|
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
||||||
|
// authority and would be false for an un-elevated admin
|
||||||
|
// who hasn't toggled yet — so we can't gate on those.
|
||||||
|
if (!access.can_elevate) return;
|
||||||
|
render(host, isElevated());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -269,7 +269,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use Local Directory" button when a tool is operating
|
||||||
in server (online) mode — the local-dir affordance is still
|
in server (online) mode — the local-dir affordance is still
|
||||||
available but visually quieter, since the typical user already
|
available but visually quieter, since the typical user already
|
||||||
has the directory loaded from the server. */
|
has the directory loaded from the server. */
|
||||||
|
|
@ -331,6 +331,11 @@ a:hover {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Let the left / right groups wrap to a second row at narrow
|
||||||
|
viewports rather than overflowing the viewport edge. row-gap
|
||||||
|
gives a small breathing strip when wrapped. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left and right groups inside .app-header. Both flex-row so their
|
/* Left and right groups inside .app-header. Both flex-row so their
|
||||||
|
|
@ -342,16 +347,35 @@ a:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Allow the title to shrink (and ellipsize) before the action
|
||||||
|
buttons get pushed off-screen at narrow viewports. */
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title group (title + build label). Made shrinkable so narrow
|
||||||
|
viewports don't push the action buttons out of view; the title
|
||||||
|
itself ellipsizes via the rule below. */
|
||||||
|
.header-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool name inside the header. Renders in the display serif so the
|
/* Tool name inside the header. Renders in the display serif so the
|
||||||
tool's identity reads as a document title, not a UI label. */
|
tool's identity reads as a document title, not a UI label.
|
||||||
|
overflow + ellipsis on min-width:0 lets the title compress
|
||||||
|
gracefully when there's no room. */
|
||||||
.app-header__title {
|
.app-header__title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|
@ -359,6 +383,9 @@ a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand logo — sits left of the title in every tool's app-header.
|
/* Brand logo — sits left of the title in every tool's app-header.
|
||||||
|
|
@ -809,61 +836,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-toggle__label {
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||||
|
so the user can't miss that admin powers are currently live.
|
||||||
|
:has(:checked) lets us style the wrapper based on the inner
|
||||||
|
checkbox without JS. */
|
||||||
|
.elevation-toggle:has(input:checked) {
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
easy to miss; these add an inescapable visual cue:
|
||||||
|
1. Thin red border around the entire viewport — peripheral-
|
||||||
|
vision reminder regardless of which tool / scroll position.
|
||||||
|
2. Sticky banner across the top with a one-click "Drop admin"
|
||||||
|
button so the user can disarm without hunting for the toggle.
|
||||||
|
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||||
|
shared/elevation.js init() syncs the body class on every page
|
||||||
|
load and tears it down when elevation is cleared.
|
||||||
|
|
||||||
|
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||||
|
reflow content or steal clicks. An inset outline on <body> was
|
||||||
|
tried first but overdrew content in tools whose root layout butts
|
||||||
|
right up to the viewport edge (browse split-pane, archive grid). */
|
||||||
|
body.is-elevated::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid var(--danger, #dc3545);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||||
|
animation: elev-pulse 1.6s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__msg {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__off {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.18rem 0.65rem;
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -1681,13 +1774,19 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.17</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||||
|
when /.profile/access reports the user has admin
|
||||||
|
authority; stays empty + hidden for non-admins so
|
||||||
|
the chrome is quiet for the common case. -->
|
||||||
|
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||||
|
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||||
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -1809,7 +1908,7 @@ body.help-open .app-header {
|
||||||
<li>Rename one file or all modified files at once</li>
|
<li>Rename one file or all modified files at once</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>Click <strong>Add Local Directory</strong> to begin.</p>
|
<p>Click <strong>Use Local Directory</strong> to begin.</p>
|
||||||
|
|
||||||
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
|
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1828,7 +1927,7 @@ body.help-open .app-header {
|
||||||
|
|
||||||
<h3>Getting Started</h3>
|
<h3>Getting Started</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Click <strong>Add Local Directory</strong> to open a folder containing files to rename.</li>
|
<li>Click <strong>Use Local Directory</strong> to open a folder containing files to rename.</li>
|
||||||
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
|
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
|
||||||
<li>Edit cells in the spreadsheet to set the new filename components.</li>
|
<li>Edit cells in the spreadsheet to set the new filename components.</li>
|
||||||
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
|
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
|
||||||
|
|
@ -3146,6 +3245,7 @@ X.B(E,Y);return E}return J}())
|
||||||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||||
'REC',
|
'REC',
|
||||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||||
|
'TBD',
|
||||||
];
|
];
|
||||||
|
|
||||||
var STATUS_SET = {};
|
var STATUS_SET = {};
|
||||||
|
|
@ -3874,13 +3974,33 @@ X.B(E,Y);return E}return J}())
|
||||||
// Top-level helpers
|
// Top-level helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
// Resolve "the directory the tool was opened in" for the current
|
||||||
// to land on the "directory the tool was opened in".
|
// page URL. Two URL shapes serve a tool:
|
||||||
|
//
|
||||||
|
// /…/<tool>.html — file URL; strip the trailing filename.
|
||||||
|
// /…/<dir>/ — trailing-slash directory URL; keep it.
|
||||||
|
// /…/<dir> — bare-directory URL served by the
|
||||||
|
// cascade's `default_tool` (e.g.
|
||||||
|
// archive/<party>/mdl serves the tables
|
||||||
|
// tool). Treat as the directory itself
|
||||||
|
// and append the missing slash.
|
||||||
|
//
|
||||||
|
// Discrimination is "does the last segment contain a dot?" — a dot
|
||||||
|
// is a reliable proxy for "looks like a file with an extension"
|
||||||
|
// since neither directory names nor default_tool paths contain
|
||||||
|
// them in this system.
|
||||||
function pathToDir(pathname) {
|
function pathToDir(pathname) {
|
||||||
if (!pathname) return '/';
|
if (!pathname) return '/';
|
||||||
if (pathname.endsWith('/')) return pathname;
|
if (pathname.endsWith('/')) return pathname;
|
||||||
var slash = pathname.lastIndexOf('/');
|
var slash = pathname.lastIndexOf('/');
|
||||||
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
|
||||||
|
if (lastSeg.indexOf('.') !== -1) {
|
||||||
|
// Has an extension → looks like a file URL → strip the
|
||||||
|
// filename to land on the parent directory.
|
||||||
|
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
||||||
|
}
|
||||||
|
// No extension → the URL IS the directory; just close it.
|
||||||
|
return pathname + '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe the server-mode root for the current page. Returns:
|
// Probe the server-mode root for the current page. Returns:
|
||||||
|
|
@ -3960,9 +4080,14 @@ X.B(E,Y);return E}return J}())
|
||||||
// srcUrl points at the .md source on the server. fmt is one of
|
// srcUrl points at the .md source on the server. fmt is one of
|
||||||
// "docx" | "html" | "pdf". The server response status maps to a
|
// "docx" | "html" | "pdf". The server response status maps to a
|
||||||
// friendly error message for the caller to surface (toast / status).
|
// friendly error message for the caller to surface (toast / status).
|
||||||
|
//
|
||||||
|
// URL grammar: srcUrl is the `<file>.md` source; the converted
|
||||||
|
// form lives at `<file>.<fmt>` (virtual file extension recognised
|
||||||
|
// by zddc-server's dispatcher). Replaces the older `?convert=`
|
||||||
|
// query form.
|
||||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||||
{ credentials: 'same-origin' });
|
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
var msg;
|
var msg;
|
||||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||||
|
|
@ -4163,211 +4288,6 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/nav.js — lateral navigation strip across the project's
|
|
||||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
||||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
||||||
// directory listing.
|
|
||||||
//
|
|
||||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
||||||
// root's JSON listing, filter to entries with `declared: true`
|
|
||||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
||||||
// render in canonical workflow order with display_name overrides
|
|
||||||
// honored. An operator who edits the project's .zddc paths: to add
|
|
||||||
// a new declared child sees it in the strip; one who removes a
|
|
||||||
// canonical entry sees the strip drop it.
|
|
||||||
//
|
|
||||||
// When the fetch fails (offline / no-server / file://), the strip
|
|
||||||
// falls back to the hardcoded four-stage list so existing
|
|
||||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
||||||
// the LAST resort — the cascade is the source of truth in normal
|
|
||||||
// operation.
|
|
||||||
//
|
|
||||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
||||||
// the stage's default tool. Operators on non-standard layouts can
|
|
||||||
// override by setting window.zddc.nav.disabled = true before
|
|
||||||
// DOMContentLoaded.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.nav) return; // already loaded
|
|
||||||
|
|
||||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
||||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
||||||
var FALLBACK_STAGES = [
|
|
||||||
{ name: 'archive', label: 'Archive' },
|
|
||||||
{ name: 'working', label: 'Working' },
|
|
||||||
{ name: 'staging', label: 'Staging' },
|
|
||||||
{ name: 'reviewing', label: 'Reviewing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Canonical workflow order. Stages appearing in this list are
|
|
||||||
// rendered in this order; any extras the cascade declares are
|
|
||||||
// appended alphabetically.
|
|
||||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
||||||
|
|
||||||
function projectSegment(pathname) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
var first = parts[0];
|
|
||||||
if (first.indexOf('.') !== -1) return null;
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStage(pathname, stages) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
var second = parts[1];
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
||||||
return stages[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (second === 'archive.html') return 'archive';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRender() {
|
|
||||||
if (typeof location === 'undefined') return false;
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
||||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
||||||
return projectSegment(location.pathname) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s) {
|
|
||||||
if (!s) return s;
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByWorkflow(stages) {
|
|
||||||
return stages.slice().sort(function (a, b) {
|
|
||||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
||||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
||||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
||||||
if (ia >= 0) return -1;
|
|
||||||
if (ib >= 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the project root listing and extract declared stage
|
|
||||||
// entries. Returns [] on any error so callers fall back to the
|
|
||||||
// hardcoded list. Each stage entry is {name, label} — label
|
|
||||||
// honors the cascade's display: override when present.
|
|
||||||
async function fetchStagesFor(project) {
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
var stages = [];
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var e = data[i];
|
|
||||||
if (!e || !e.declared || !e.is_dir) continue;
|
|
||||||
var bare = (e.name || '').replace(/\/$/, '');
|
|
||||||
if (!bare) continue;
|
|
||||||
stages.push({
|
|
||||||
name: bare,
|
|
||||||
label: e.display_name || titleCase(bare),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sortByWorkflow(stages);
|
|
||||||
} catch (_e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrip(project, active, stages) {
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.className = 'zddc-stage-strip';
|
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'zddc-stage-strip__project';
|
|
||||||
label.textContent = project;
|
|
||||||
nav.appendChild(label);
|
|
||||||
|
|
||||||
var sep0 = document.createElement('span');
|
|
||||||
sep0.className = 'zddc-stage-strip__divider';
|
|
||||||
sep0.setAttribute('aria-hidden', 'true');
|
|
||||||
sep0.textContent = '/';
|
|
||||||
nav.appendChild(sep0);
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
var s = stages[i];
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'zddc-stage';
|
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
||||||
a.textContent = s.label;
|
|
||||||
if (s.name === active) {
|
|
||||||
a.classList.add('zddc-stage--active');
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
nav.appendChild(a);
|
|
||||||
|
|
||||||
if (i < stages.length - 1) {
|
|
||||||
var sep = document.createElement('span');
|
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
|
||||||
sep.textContent = '·';
|
|
||||||
nav.appendChild(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountWith(project, stages) {
|
|
||||||
var header = document.querySelector('.app-header');
|
|
||||||
if (!header) return;
|
|
||||||
if (header.previousElementSibling &&
|
|
||||||
header.previousElementSibling.classList &&
|
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
||||||
return; // already mounted
|
|
||||||
}
|
|
||||||
var active = currentStage(location.pathname, stages);
|
|
||||||
var strip = buildStrip(project, active, stages);
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
|
||||||
if (!shouldRender()) return;
|
|
||||||
var project = projectSegment(location.pathname);
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Render the hardcoded fallback immediately so the strip
|
|
||||||
// appears with no flicker, then upgrade to cascade-resolved
|
|
||||||
// stages once the fetch completes.
|
|
||||||
mountWith(project, FALLBACK_STAGES);
|
|
||||||
|
|
||||||
var fetched = await fetchStagesFor(project);
|
|
||||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
||||||
|
|
||||||
// Replace the strip with the cascade-driven one. Remove the
|
|
||||||
// existing strip first so mountWith re-mounts cleanly.
|
|
||||||
var existing = document.querySelector('.zddc-stage-strip');
|
|
||||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
||||||
mountWith(project, fetched);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.nav = {
|
|
||||||
mount: mount,
|
|
||||||
_projectSegment: projectSegment,
|
|
||||||
_currentStage: currentStage,
|
|
||||||
_fallbackStages: FALLBACK_STAGES,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
||||||
} else {
|
|
||||||
mount();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||||||
// every tool's header into a clickable link. The destination is the
|
// every tool's header into a clickable link. The destination is the
|
||||||
// nearest "home" the user can sensibly back out to:
|
// nearest "home" the user can sensibly back out to:
|
||||||
|
|
@ -9925,6 +9845,155 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
// shared/elevation.js — admin elevation toggle.
|
||||||
|
//
|
||||||
|
// Sudo-style model: admins behave as normal users by default; clicking
|
||||||
|
// the header toggle elevates the session so admin escape hatches (WORM
|
||||||
|
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
||||||
|
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
||||||
|
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||||
|
//
|
||||||
|
// Only renders the toggle when /.profile/access reports the caller has
|
||||||
|
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
||||||
|
// quiet for the common case. The toggle fades in once access loads so
|
||||||
|
// non-admins never even see the affordance flash.
|
||||||
|
//
|
||||||
|
// Click flow: set/clear the cookie, then reload the page so the server
|
||||||
|
// sees the new state on the next render. The reload is intentional —
|
||||||
|
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
||||||
|
// a soft state flip on the client alone wouldn't reach those.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
if (window.zddc.elevation) return;
|
||||||
|
|
||||||
|
var COOKIE_NAME = 'zddc-elevate';
|
||||||
|
|
||||||
|
function isElevated() {
|
||||||
|
var parts = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var kv = parts[i].trim().split('=');
|
||||||
|
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElevated(on) {
|
||||||
|
if (on) {
|
||||||
|
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||||||
|
// shapes. Max-Age caps the elevation window so a forgotten
|
||||||
|
// tab doesn't leave admin powers active indefinitely (sudo's
|
||||||
|
// 5-minute precedent informs the number — 30 minutes is a
|
||||||
|
// reasonable trade between annoyance and exposure).
|
||||||
|
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
||||||
|
} else {
|
||||||
|
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccess() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/.profile/access', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return await resp.json();
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(host, elevated) {
|
||||||
|
host.classList.remove('hidden');
|
||||||
|
host.innerHTML =
|
||||||
|
'<input type="checkbox" id="elevation-checkbox"'
|
||||||
|
+ (elevated ? ' checked' : '') + '>'
|
||||||
|
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
||||||
|
+ 'Admin</label>';
|
||||||
|
var cb = host.querySelector('#elevation-checkbox');
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
setElevated(cb.checked);
|
||||||
|
// Hard reload so server-rendered admin surfaces (profile
|
||||||
|
// page scaffolds, hidden-entry listings) catch up. URL
|
||||||
|
// and scroll state are preserved by the browser's normal
|
||||||
|
// back-forward cache rules.
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-wide affordances when elevation is active. The toggle alone
|
||||||
|
// is easy to miss — admin mode silently bypasses WORM and ACL
|
||||||
|
// restrictions, which produces surprising "I shouldn't have been
|
||||||
|
// able to do that" moments. A body class + a sticky banner with a
|
||||||
|
// one-click disable make the armed state unmistakable.
|
||||||
|
function applyArmedChrome(elevated) {
|
||||||
|
var b = document.body;
|
||||||
|
if (!b) return;
|
||||||
|
if (elevated) b.classList.add('is-elevated');
|
||||||
|
else b.classList.remove('is-elevated');
|
||||||
|
|
||||||
|
var banner = document.getElementById('elevation-banner');
|
||||||
|
if (elevated) {
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'elevation-banner';
|
||||||
|
banner.className = 'elevation-banner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.innerHTML =
|
||||||
|
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
||||||
|
+ '<span class="elevation-banner__msg">'
|
||||||
|
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
||||||
|
+ '</span>'
|
||||||
|
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
||||||
|
+ 'Drop admin'
|
||||||
|
+ '</button>';
|
||||||
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
|
var off = banner.querySelector('#elevation-banner-off');
|
||||||
|
if (off) off.addEventListener('click', function () {
|
||||||
|
setElevated(false);
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (banner) {
|
||||||
|
banner.parentNode.removeChild(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Body chrome applies on every page load whether or not the
|
||||||
|
// header has a toggle slot — the banner needs to surface in
|
||||||
|
// tools / pages that don't host the toggle (e.g. iframed
|
||||||
|
// classifier inside browse's grid mode), so the user can't
|
||||||
|
// accidentally write through an elevated context elsewhere.
|
||||||
|
applyArmedChrome(isElevated());
|
||||||
|
|
||||||
|
var host = document.getElementById('elevation-toggle');
|
||||||
|
if (!host) return; // tool doesn't include the slot yet — no-op
|
||||||
|
var access = await fetchAccess();
|
||||||
|
if (!access) return; // anonymous / endpoint missing — no-op
|
||||||
|
// Surface ONLY for users who have admin authority somewhere.
|
||||||
|
// /.profile/access ships `can_elevate` as an elevation-
|
||||||
|
// INDEPENDENT signal — true for any user named in any admin
|
||||||
|
// list, regardless of current cookie state. The other flags
|
||||||
|
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
||||||
|
// authority and would be false for an un-elevated admin
|
||||||
|
// who hasn't toggled yet — so we can't gate on those.
|
||||||
|
if (!access.can_elevate) return;
|
||||||
|
render(host, isElevated());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use Local Directory" button when a tool is operating
|
||||||
in server (online) mode — the local-dir affordance is still
|
in server (online) mode — the local-dir affordance is still
|
||||||
available but visually quieter, since the typical user already
|
available but visually quieter, since the typical user already
|
||||||
has the directory loaded from the server. */
|
has the directory loaded from the server. */
|
||||||
|
|
@ -331,6 +331,11 @@ a:hover {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Let the left / right groups wrap to a second row at narrow
|
||||||
|
viewports rather than overflowing the viewport edge. row-gap
|
||||||
|
gives a small breathing strip when wrapped. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left and right groups inside .app-header. Both flex-row so their
|
/* Left and right groups inside .app-header. Both flex-row so their
|
||||||
|
|
@ -342,16 +347,35 @@ a:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Allow the title to shrink (and ellipsize) before the action
|
||||||
|
buttons get pushed off-screen at narrow viewports. */
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title group (title + build label). Made shrinkable so narrow
|
||||||
|
viewports don't push the action buttons out of view; the title
|
||||||
|
itself ellipsizes via the rule below. */
|
||||||
|
.header-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool name inside the header. Renders in the display serif so the
|
/* Tool name inside the header. Renders in the display serif so the
|
||||||
tool's identity reads as a document title, not a UI label. */
|
tool's identity reads as a document title, not a UI label.
|
||||||
|
overflow + ellipsis on min-width:0 lets the title compress
|
||||||
|
gracefully when there's no room. */
|
||||||
.app-header__title {
|
.app-header__title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|
@ -359,6 +383,9 @@ a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand logo — sits left of the title in every tool's app-header.
|
/* Brand logo — sits left of the title in every tool's app-header.
|
||||||
|
|
@ -809,61 +836,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-toggle__label {
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||||
|
so the user can't miss that admin powers are currently live.
|
||||||
|
:has(:checked) lets us style the wrapper based on the inner
|
||||||
|
checkbox without JS. */
|
||||||
|
.elevation-toggle:has(input:checked) {
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
easy to miss; these add an inescapable visual cue:
|
||||||
|
1. Thin red border around the entire viewport — peripheral-
|
||||||
|
vision reminder regardless of which tool / scroll position.
|
||||||
|
2. Sticky banner across the top with a one-click "Drop admin"
|
||||||
|
button so the user can disarm without hunting for the toggle.
|
||||||
|
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||||
|
shared/elevation.js init() syncs the body class on every page
|
||||||
|
load and tears it down when elevation is cleared.
|
||||||
|
|
||||||
|
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||||
|
reflow content or steal clicks. An inset outline on <body> was
|
||||||
|
tried first but overdrew content in tools whose root layout butts
|
||||||
|
right up to the viewport edge (browse split-pane, archive grid). */
|
||||||
|
body.is-elevated::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid var(--danger, #dc3545);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||||
|
animation: elev-pulse 1.6s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__msg {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__off {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.18rem 0.65rem;
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -1424,10 +1517,16 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.17</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||||
|
when /.profile/access reports the user has admin
|
||||||
|
authority; stays empty + hidden for non-admins so
|
||||||
|
the chrome is quiet for the common case. -->
|
||||||
|
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||||
|
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1632,6 +1731,7 @@ body {
|
||||||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||||
'REC',
|
'REC',
|
||||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||||
|
'TBD',
|
||||||
];
|
];
|
||||||
|
|
||||||
var STATUS_SET = {};
|
var STATUS_SET = {};
|
||||||
|
|
@ -2297,211 +2397,6 @@ body {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/nav.js — lateral navigation strip across the project's
|
|
||||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
||||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
||||||
// directory listing.
|
|
||||||
//
|
|
||||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
||||||
// root's JSON listing, filter to entries with `declared: true`
|
|
||||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
||||||
// render in canonical workflow order with display_name overrides
|
|
||||||
// honored. An operator who edits the project's .zddc paths: to add
|
|
||||||
// a new declared child sees it in the strip; one who removes a
|
|
||||||
// canonical entry sees the strip drop it.
|
|
||||||
//
|
|
||||||
// When the fetch fails (offline / no-server / file://), the strip
|
|
||||||
// falls back to the hardcoded four-stage list so existing
|
|
||||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
||||||
// the LAST resort — the cascade is the source of truth in normal
|
|
||||||
// operation.
|
|
||||||
//
|
|
||||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
||||||
// the stage's default tool. Operators on non-standard layouts can
|
|
||||||
// override by setting window.zddc.nav.disabled = true before
|
|
||||||
// DOMContentLoaded.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.nav) return; // already loaded
|
|
||||||
|
|
||||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
||||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
||||||
var FALLBACK_STAGES = [
|
|
||||||
{ name: 'archive', label: 'Archive' },
|
|
||||||
{ name: 'working', label: 'Working' },
|
|
||||||
{ name: 'staging', label: 'Staging' },
|
|
||||||
{ name: 'reviewing', label: 'Reviewing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Canonical workflow order. Stages appearing in this list are
|
|
||||||
// rendered in this order; any extras the cascade declares are
|
|
||||||
// appended alphabetically.
|
|
||||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
||||||
|
|
||||||
function projectSegment(pathname) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
var first = parts[0];
|
|
||||||
if (first.indexOf('.') !== -1) return null;
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStage(pathname, stages) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
var second = parts[1];
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
||||||
return stages[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (second === 'archive.html') return 'archive';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRender() {
|
|
||||||
if (typeof location === 'undefined') return false;
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
||||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
||||||
return projectSegment(location.pathname) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s) {
|
|
||||||
if (!s) return s;
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByWorkflow(stages) {
|
|
||||||
return stages.slice().sort(function (a, b) {
|
|
||||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
||||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
||||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
||||||
if (ia >= 0) return -1;
|
|
||||||
if (ib >= 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the project root listing and extract declared stage
|
|
||||||
// entries. Returns [] on any error so callers fall back to the
|
|
||||||
// hardcoded list. Each stage entry is {name, label} — label
|
|
||||||
// honors the cascade's display: override when present.
|
|
||||||
async function fetchStagesFor(project) {
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
var stages = [];
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var e = data[i];
|
|
||||||
if (!e || !e.declared || !e.is_dir) continue;
|
|
||||||
var bare = (e.name || '').replace(/\/$/, '');
|
|
||||||
if (!bare) continue;
|
|
||||||
stages.push({
|
|
||||||
name: bare,
|
|
||||||
label: e.display_name || titleCase(bare),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sortByWorkflow(stages);
|
|
||||||
} catch (_e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrip(project, active, stages) {
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.className = 'zddc-stage-strip';
|
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'zddc-stage-strip__project';
|
|
||||||
label.textContent = project;
|
|
||||||
nav.appendChild(label);
|
|
||||||
|
|
||||||
var sep0 = document.createElement('span');
|
|
||||||
sep0.className = 'zddc-stage-strip__divider';
|
|
||||||
sep0.setAttribute('aria-hidden', 'true');
|
|
||||||
sep0.textContent = '/';
|
|
||||||
nav.appendChild(sep0);
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
var s = stages[i];
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'zddc-stage';
|
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
||||||
a.textContent = s.label;
|
|
||||||
if (s.name === active) {
|
|
||||||
a.classList.add('zddc-stage--active');
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
nav.appendChild(a);
|
|
||||||
|
|
||||||
if (i < stages.length - 1) {
|
|
||||||
var sep = document.createElement('span');
|
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
|
||||||
sep.textContent = '·';
|
|
||||||
nav.appendChild(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountWith(project, stages) {
|
|
||||||
var header = document.querySelector('.app-header');
|
|
||||||
if (!header) return;
|
|
||||||
if (header.previousElementSibling &&
|
|
||||||
header.previousElementSibling.classList &&
|
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
||||||
return; // already mounted
|
|
||||||
}
|
|
||||||
var active = currentStage(location.pathname, stages);
|
|
||||||
var strip = buildStrip(project, active, stages);
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
|
||||||
if (!shouldRender()) return;
|
|
||||||
var project = projectSegment(location.pathname);
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Render the hardcoded fallback immediately so the strip
|
|
||||||
// appears with no flicker, then upgrade to cascade-resolved
|
|
||||||
// stages once the fetch completes.
|
|
||||||
mountWith(project, FALLBACK_STAGES);
|
|
||||||
|
|
||||||
var fetched = await fetchStagesFor(project);
|
|
||||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
||||||
|
|
||||||
// Replace the strip with the cascade-driven one. Remove the
|
|
||||||
// existing strip first so mountWith re-mounts cleanly.
|
|
||||||
var existing = document.querySelector('.zddc-stage-strip');
|
|
||||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
||||||
mountWith(project, fetched);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.nav = {
|
|
||||||
mount: mount,
|
|
||||||
_projectSegment: projectSegment,
|
|
||||||
_currentStage: currentStage,
|
|
||||||
_fallbackStages: FALLBACK_STAGES,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
||||||
} else {
|
|
||||||
mount();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||||||
// every tool's header into a clickable link. The destination is the
|
// every tool's header into a clickable link. The destination is the
|
||||||
// nearest "home" the user can sensibly back out to:
|
// nearest "home" the user can sensibly back out to:
|
||||||
|
|
@ -2632,6 +2527,155 @@ body {
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
// shared/elevation.js — admin elevation toggle.
|
||||||
|
//
|
||||||
|
// Sudo-style model: admins behave as normal users by default; clicking
|
||||||
|
// the header toggle elevates the session so admin escape hatches (WORM
|
||||||
|
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
||||||
|
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
||||||
|
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||||
|
//
|
||||||
|
// Only renders the toggle when /.profile/access reports the caller has
|
||||||
|
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
||||||
|
// quiet for the common case. The toggle fades in once access loads so
|
||||||
|
// non-admins never even see the affordance flash.
|
||||||
|
//
|
||||||
|
// Click flow: set/clear the cookie, then reload the page so the server
|
||||||
|
// sees the new state on the next render. The reload is intentional —
|
||||||
|
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
||||||
|
// a soft state flip on the client alone wouldn't reach those.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
if (window.zddc.elevation) return;
|
||||||
|
|
||||||
|
var COOKIE_NAME = 'zddc-elevate';
|
||||||
|
|
||||||
|
function isElevated() {
|
||||||
|
var parts = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var kv = parts[i].trim().split('=');
|
||||||
|
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElevated(on) {
|
||||||
|
if (on) {
|
||||||
|
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||||||
|
// shapes. Max-Age caps the elevation window so a forgotten
|
||||||
|
// tab doesn't leave admin powers active indefinitely (sudo's
|
||||||
|
// 5-minute precedent informs the number — 30 minutes is a
|
||||||
|
// reasonable trade between annoyance and exposure).
|
||||||
|
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
||||||
|
} else {
|
||||||
|
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccess() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/.profile/access', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return await resp.json();
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(host, elevated) {
|
||||||
|
host.classList.remove('hidden');
|
||||||
|
host.innerHTML =
|
||||||
|
'<input type="checkbox" id="elevation-checkbox"'
|
||||||
|
+ (elevated ? ' checked' : '') + '>'
|
||||||
|
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
||||||
|
+ 'Admin</label>';
|
||||||
|
var cb = host.querySelector('#elevation-checkbox');
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
setElevated(cb.checked);
|
||||||
|
// Hard reload so server-rendered admin surfaces (profile
|
||||||
|
// page scaffolds, hidden-entry listings) catch up. URL
|
||||||
|
// and scroll state are preserved by the browser's normal
|
||||||
|
// back-forward cache rules.
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-wide affordances when elevation is active. The toggle alone
|
||||||
|
// is easy to miss — admin mode silently bypasses WORM and ACL
|
||||||
|
// restrictions, which produces surprising "I shouldn't have been
|
||||||
|
// able to do that" moments. A body class + a sticky banner with a
|
||||||
|
// one-click disable make the armed state unmistakable.
|
||||||
|
function applyArmedChrome(elevated) {
|
||||||
|
var b = document.body;
|
||||||
|
if (!b) return;
|
||||||
|
if (elevated) b.classList.add('is-elevated');
|
||||||
|
else b.classList.remove('is-elevated');
|
||||||
|
|
||||||
|
var banner = document.getElementById('elevation-banner');
|
||||||
|
if (elevated) {
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'elevation-banner';
|
||||||
|
banner.className = 'elevation-banner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.innerHTML =
|
||||||
|
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
||||||
|
+ '<span class="elevation-banner__msg">'
|
||||||
|
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
||||||
|
+ '</span>'
|
||||||
|
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
||||||
|
+ 'Drop admin'
|
||||||
|
+ '</button>';
|
||||||
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
|
var off = banner.querySelector('#elevation-banner-off');
|
||||||
|
if (off) off.addEventListener('click', function () {
|
||||||
|
setElevated(false);
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (banner) {
|
||||||
|
banner.parentNode.removeChild(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Body chrome applies on every page load whether or not the
|
||||||
|
// header has a toggle slot — the banner needs to surface in
|
||||||
|
// tools / pages that don't host the toggle (e.g. iframed
|
||||||
|
// classifier inside browse's grid mode), so the user can't
|
||||||
|
// accidentally write through an elevated context elsewhere.
|
||||||
|
applyArmedChrome(isElevated());
|
||||||
|
|
||||||
|
var host = document.getElementById('elevation-toggle');
|
||||||
|
if (!host) return; // tool doesn't include the slot yet — no-op
|
||||||
|
var access = await fetchAccess();
|
||||||
|
if (!access) return; // anonymous / endpoint missing — no-op
|
||||||
|
// Surface ONLY for users who have admin authority somewhere.
|
||||||
|
// /.profile/access ships `can_elevate` as an elevation-
|
||||||
|
// INDEPENDENT signal — true for any user named in any admin
|
||||||
|
// list, regardless of current cookie state. The other flags
|
||||||
|
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
||||||
|
// authority and would be false for an un-elevated admin
|
||||||
|
// who hasn't toggled yet — so we can't gate on those.
|
||||||
|
if (!access.can_elevate) return;
|
||||||
|
render(host, isElevated());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
|
})();
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
// ZDDC landing page — project picker.
|
// ZDDC landing page — project picker.
|
||||||
|
|
@ -2762,13 +2806,27 @@ body {
|
||||||
|
|
||||||
var data = JSON.parse(body);
|
var data = JSON.parse(body);
|
||||||
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
|
if (!Array.isArray(data)) throw new Error('Expected a JSON array of projects, got ' + typeof data);
|
||||||
allProjects = data.map(function(p) {
|
// The root JSON is now a generic listing.FileInfo[] (same
|
||||||
return {
|
// shape every other directory returns). Filter to
|
||||||
name: String(p.name || ''),
|
// directories (projects are folders), strip the trailing
|
||||||
title: String(p.title || ''),
|
// "/" the server adds to dir names, and pick up `title`
|
||||||
url: String(p.url || '')
|
// (the per-project .zddc title:, populated by the
|
||||||
};
|
// server-side listing pipeline).
|
||||||
}).filter(function(p) { return p.name; });
|
allProjects = data
|
||||||
|
.filter(function (p) { return p && p.is_dir; })
|
||||||
|
.map(function (p) {
|
||||||
|
var raw = String(p.name || '').replace(/\/$/, '');
|
||||||
|
return {
|
||||||
|
name: raw,
|
||||||
|
title: String(p.title || ''),
|
||||||
|
url: String(p.url || '')
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(function (p) {
|
||||||
|
if (!p.name) return false;
|
||||||
|
var c = p.name.charAt(0);
|
||||||
|
return c !== '.' && c !== '_';
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e.message || String(e);
|
loadError = e.message || String(e);
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ a:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subdued / de-emphasized variant.
|
/* Subdued / de-emphasized variant.
|
||||||
Used on the "Add Local Directory" button when a tool is operating
|
Used on the "Use Local Directory" button when a tool is operating
|
||||||
in server (online) mode — the local-dir affordance is still
|
in server (online) mode — the local-dir affordance is still
|
||||||
available but visually quieter, since the typical user already
|
available but visually quieter, since the typical user already
|
||||||
has the directory loaded from the server. */
|
has the directory loaded from the server. */
|
||||||
|
|
@ -335,6 +335,11 @@ a:hover {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Let the left / right groups wrap to a second row at narrow
|
||||||
|
viewports rather than overflowing the viewport edge. row-gap
|
||||||
|
gives a small breathing strip when wrapped. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left and right groups inside .app-header. Both flex-row so their
|
/* Left and right groups inside .app-header. Both flex-row so their
|
||||||
|
|
@ -346,16 +351,35 @@ a:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Allow the title to shrink (and ellipsize) before the action
|
||||||
|
buttons get pushed off-screen at narrow viewports. */
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title group (title + build label). Made shrinkable so narrow
|
||||||
|
viewports don't push the action buttons out of view; the title
|
||||||
|
itself ellipsizes via the rule below. */
|
||||||
|
.header-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tool name inside the header. Renders in the display serif so the
|
/* Tool name inside the header. Renders in the display serif so the
|
||||||
tool's identity reads as a document title, not a UI label. */
|
tool's identity reads as a document title, not a UI label.
|
||||||
|
overflow + ellipsis on min-width:0 lets the title compress
|
||||||
|
gracefully when there's no room. */
|
||||||
.app-header__title {
|
.app-header__title {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|
@ -363,6 +387,9 @@ a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand logo — sits left of the title in every tool's app-header.
|
/* Brand logo — sits left of the title in every tool's app-header.
|
||||||
|
|
@ -813,61 +840,127 @@ body.help-open .app-header {
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/nav.css — lateral project-stage strip paired with shared/nav.js.
|
/* shared/elevation.css — admin-elevation toggle in the tool header.
|
||||||
Sits as a sibling immediately under .app-header (mounted by JS).
|
Renders only for users with admin scope (handled by elevation.js;
|
||||||
Rendered only in online mode when a project segment is in the URL. */
|
the placeholder is `.hidden` by default). When visible, sits left
|
||||||
|
of the theme button — sudo-style affordance for opting into admin
|
||||||
|
powers. */
|
||||||
|
|
||||||
.zddc-stage-strip {
|
.elevation-toggle {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
padding: 0.3rem 1rem;
|
font-size: 0.78rem;
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__project {
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage-strip__divider,
|
|
||||||
.zddc-stage-strip__sep {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
cursor: pointer;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
.zddc-stage-strip__divider {
|
border: 1px solid var(--border);
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.1rem 0.25rem;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
background: var(--bg);
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage:hover {
|
.elevation-toggle:hover {
|
||||||
color: var(--text);
|
background: var(--bg-hover);
|
||||||
background: var(--bg-secondary);
|
border-color: var(--border-dark);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active {
|
.elevation-toggle input[type="checkbox"] {
|
||||||
color: var(--primary);
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-toggle__label {
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state — when elevation is ON, the toggle reads as "armed"
|
||||||
|
so the user can't miss that admin powers are currently live.
|
||||||
|
:has(:checked) lets us style the wrapper based on the inner
|
||||||
|
checkbox without JS. */
|
||||||
|
.elevation-toggle:has(input:checked) {
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
color: var(--primary);
|
easy to miss; these add an inescapable visual cue:
|
||||||
|
1. Thin red border around the entire viewport — peripheral-
|
||||||
|
vision reminder regardless of which tool / scroll position.
|
||||||
|
2. Sticky banner across the top with a one-click "Drop admin"
|
||||||
|
button so the user can disarm without hunting for the toggle.
|
||||||
|
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||||
|
shared/elevation.js init() syncs the body class on every page
|
||||||
|
load and tears it down when elevation is cleared.
|
||||||
|
|
||||||
|
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||||
|
reflow content or steal clicks. An inset outline on <body> was
|
||||||
|
tried first but overdrew content in tools whose root layout butts
|
||||||
|
right up to the viewport edge (browse split-pane, archive grid). */
|
||||||
|
body.is-elevated::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid var(--danger, #dc3545);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: rgba(220, 53, 69, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 9100; /* above modal-overlay (9000) so it's never hidden */
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||||
|
animation: elev-pulse 1.6s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes elev-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__msg {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.elevation-banner__off {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.18rem 0.65rem;
|
||||||
|
border-radius: var(--radius, 4px);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.elevation-banner__off:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
|
|
@ -2523,11 +2616,11 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552</span></span>
|
<span class="build-timestamp">v0.0.17</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
other tools have "Add Local Directory" here instead) -->
|
other tools have "Use Local Directory" here instead) -->
|
||||||
<div class="split-button" id="bottom-menu" hidden>
|
<div class="split-button" id="bottom-menu" hidden>
|
||||||
<button id="bottom-toggle" type="button" class="btn btn-primary split-button__toggle" data-no-disable="true" aria-haspopup="true" aria-expanded="false">▾</button>
|
<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>
|
<button id="bottom-primary" type="button" class="btn btn-primary" data-no-disable="true">Publish</button>
|
||||||
|
|
@ -2535,6 +2628,12 @@ dialog.modal--narrow {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<!-- Elevation toggle slot. shared/elevation.js fills it
|
||||||
|
when /.profile/access reports the user has admin
|
||||||
|
authority; stays empty + hidden for non-admins so
|
||||||
|
the chrome is quiet for the common case. -->
|
||||||
|
<span id="elevation-toggle" class="elevation-toggle hidden"
|
||||||
|
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
|
||||||
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button type="button" id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
<button type="button" id="help-btn" class="btn btn-secondary" aria-label="Help" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4202,6 +4301,7 @@ X.B(E,Y);return E}return J}())
|
||||||
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
|
||||||
'REC',
|
'REC',
|
||||||
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
|
||||||
|
'TBD',
|
||||||
];
|
];
|
||||||
|
|
||||||
var STATUS_SET = {};
|
var STATUS_SET = {};
|
||||||
|
|
@ -4930,13 +5030,33 @@ X.B(E,Y);return E}return J}())
|
||||||
// Top-level helpers
|
// Top-level helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
// Strip a trailing tool .html (e.g. classifier.html) from a path
|
// Resolve "the directory the tool was opened in" for the current
|
||||||
// to land on the "directory the tool was opened in".
|
// page URL. Two URL shapes serve a tool:
|
||||||
|
//
|
||||||
|
// /…/<tool>.html — file URL; strip the trailing filename.
|
||||||
|
// /…/<dir>/ — trailing-slash directory URL; keep it.
|
||||||
|
// /…/<dir> — bare-directory URL served by the
|
||||||
|
// cascade's `default_tool` (e.g.
|
||||||
|
// archive/<party>/mdl serves the tables
|
||||||
|
// tool). Treat as the directory itself
|
||||||
|
// and append the missing slash.
|
||||||
|
//
|
||||||
|
// Discrimination is "does the last segment contain a dot?" — a dot
|
||||||
|
// is a reliable proxy for "looks like a file with an extension"
|
||||||
|
// since neither directory names nor default_tool paths contain
|
||||||
|
// them in this system.
|
||||||
function pathToDir(pathname) {
|
function pathToDir(pathname) {
|
||||||
if (!pathname) return '/';
|
if (!pathname) return '/';
|
||||||
if (pathname.endsWith('/')) return pathname;
|
if (pathname.endsWith('/')) return pathname;
|
||||||
var slash = pathname.lastIndexOf('/');
|
var slash = pathname.lastIndexOf('/');
|
||||||
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname;
|
||||||
|
if (lastSeg.indexOf('.') !== -1) {
|
||||||
|
// Has an extension → looks like a file URL → strip the
|
||||||
|
// filename to land on the parent directory.
|
||||||
|
return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
|
||||||
|
}
|
||||||
|
// No extension → the URL IS the directory; just close it.
|
||||||
|
return pathname + '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe the server-mode root for the current page. Returns:
|
// Probe the server-mode root for the current page. Returns:
|
||||||
|
|
@ -5016,9 +5136,14 @@ X.B(E,Y);return E}return J}())
|
||||||
// srcUrl points at the .md source on the server. fmt is one of
|
// srcUrl points at the .md source on the server. fmt is one of
|
||||||
// "docx" | "html" | "pdf". The server response status maps to a
|
// "docx" | "html" | "pdf". The server response status maps to a
|
||||||
// friendly error message for the caller to surface (toast / status).
|
// friendly error message for the caller to surface (toast / status).
|
||||||
|
//
|
||||||
|
// URL grammar: srcUrl is the `<file>.md` source; the converted
|
||||||
|
// form lives at `<file>.<fmt>` (virtual file extension recognised
|
||||||
|
// by zddc-server's dispatcher). Replaces the older `?convert=`
|
||||||
|
// query form.
|
||||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||||
{ credentials: 'same-origin' });
|
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
var msg;
|
var msg;
|
||||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||||
|
|
@ -5219,211 +5344,6 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/nav.js — lateral navigation strip across the project's
|
|
||||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
||||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
||||||
// directory listing.
|
|
||||||
//
|
|
||||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
||||||
// root's JSON listing, filter to entries with `declared: true`
|
|
||||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
||||||
// render in canonical workflow order with display_name overrides
|
|
||||||
// honored. An operator who edits the project's .zddc paths: to add
|
|
||||||
// a new declared child sees it in the strip; one who removes a
|
|
||||||
// canonical entry sees the strip drop it.
|
|
||||||
//
|
|
||||||
// When the fetch fails (offline / no-server / file://), the strip
|
|
||||||
// falls back to the hardcoded four-stage list so existing
|
|
||||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
||||||
// the LAST resort — the cascade is the source of truth in normal
|
|
||||||
// operation.
|
|
||||||
//
|
|
||||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
||||||
// the stage's default tool. Operators on non-standard layouts can
|
|
||||||
// override by setting window.zddc.nav.disabled = true before
|
|
||||||
// DOMContentLoaded.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.nav) return; // already loaded
|
|
||||||
|
|
||||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
||||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
||||||
var FALLBACK_STAGES = [
|
|
||||||
{ name: 'archive', label: 'Archive' },
|
|
||||||
{ name: 'working', label: 'Working' },
|
|
||||||
{ name: 'staging', label: 'Staging' },
|
|
||||||
{ name: 'reviewing', label: 'Reviewing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Canonical workflow order. Stages appearing in this list are
|
|
||||||
// rendered in this order; any extras the cascade declares are
|
|
||||||
// appended alphabetically.
|
|
||||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
||||||
|
|
||||||
function projectSegment(pathname) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
var first = parts[0];
|
|
||||||
if (first.indexOf('.') !== -1) return null;
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStage(pathname, stages) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
var second = parts[1];
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
||||||
return stages[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (second === 'archive.html') return 'archive';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRender() {
|
|
||||||
if (typeof location === 'undefined') return false;
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
||||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
||||||
return projectSegment(location.pathname) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s) {
|
|
||||||
if (!s) return s;
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByWorkflow(stages) {
|
|
||||||
return stages.slice().sort(function (a, b) {
|
|
||||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
||||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
||||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
||||||
if (ia >= 0) return -1;
|
|
||||||
if (ib >= 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the project root listing and extract declared stage
|
|
||||||
// entries. Returns [] on any error so callers fall back to the
|
|
||||||
// hardcoded list. Each stage entry is {name, label} — label
|
|
||||||
// honors the cascade's display: override when present.
|
|
||||||
async function fetchStagesFor(project) {
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
var stages = [];
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var e = data[i];
|
|
||||||
if (!e || !e.declared || !e.is_dir) continue;
|
|
||||||
var bare = (e.name || '').replace(/\/$/, '');
|
|
||||||
if (!bare) continue;
|
|
||||||
stages.push({
|
|
||||||
name: bare,
|
|
||||||
label: e.display_name || titleCase(bare),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sortByWorkflow(stages);
|
|
||||||
} catch (_e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrip(project, active, stages) {
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.className = 'zddc-stage-strip';
|
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'zddc-stage-strip__project';
|
|
||||||
label.textContent = project;
|
|
||||||
nav.appendChild(label);
|
|
||||||
|
|
||||||
var sep0 = document.createElement('span');
|
|
||||||
sep0.className = 'zddc-stage-strip__divider';
|
|
||||||
sep0.setAttribute('aria-hidden', 'true');
|
|
||||||
sep0.textContent = '/';
|
|
||||||
nav.appendChild(sep0);
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
var s = stages[i];
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'zddc-stage';
|
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
||||||
a.textContent = s.label;
|
|
||||||
if (s.name === active) {
|
|
||||||
a.classList.add('zddc-stage--active');
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
nav.appendChild(a);
|
|
||||||
|
|
||||||
if (i < stages.length - 1) {
|
|
||||||
var sep = document.createElement('span');
|
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
|
||||||
sep.textContent = '·';
|
|
||||||
nav.appendChild(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountWith(project, stages) {
|
|
||||||
var header = document.querySelector('.app-header');
|
|
||||||
if (!header) return;
|
|
||||||
if (header.previousElementSibling &&
|
|
||||||
header.previousElementSibling.classList &&
|
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
||||||
return; // already mounted
|
|
||||||
}
|
|
||||||
var active = currentStage(location.pathname, stages);
|
|
||||||
var strip = buildStrip(project, active, stages);
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
|
||||||
if (!shouldRender()) return;
|
|
||||||
var project = projectSegment(location.pathname);
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Render the hardcoded fallback immediately so the strip
|
|
||||||
// appears with no flicker, then upgrade to cascade-resolved
|
|
||||||
// stages once the fetch completes.
|
|
||||||
mountWith(project, FALLBACK_STAGES);
|
|
||||||
|
|
||||||
var fetched = await fetchStagesFor(project);
|
|
||||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
||||||
|
|
||||||
// Replace the strip with the cascade-driven one. Remove the
|
|
||||||
// existing strip first so mountWith re-mounts cleanly.
|
|
||||||
var existing = document.querySelector('.zddc-stage-strip');
|
|
||||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
||||||
mountWith(project, fetched);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.nav = {
|
|
||||||
mount: mount,
|
|
||||||
_projectSegment: projectSegment,
|
|
||||||
_currentStage: currentStage,
|
|
||||||
_fallbackStages: FALLBACK_STAGES,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
||||||
} else {
|
|
||||||
mount();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||||||
// every tool's header into a clickable link. The destination is the
|
// every tool's header into a clickable link. The destination is the
|
||||||
// nearest "home" the user can sensibly back out to:
|
// nearest "home" the user can sensibly back out to:
|
||||||
|
|
@ -13349,6 +13269,155 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
// shared/elevation.js — admin elevation toggle.
|
||||||
|
//
|
||||||
|
// Sudo-style model: admins behave as normal users by default; clicking
|
||||||
|
// the header toggle elevates the session so admin escape hatches (WORM
|
||||||
|
// bypass, .zddc edit authority, profile admin scaffolds) start firing.
|
||||||
|
// State is carried in a `zddc-elevate=1` cookie that the server reads
|
||||||
|
// via handler.ACLMiddleware → zddc.Principal{Elevated}.
|
||||||
|
//
|
||||||
|
// Only renders the toggle when /.profile/access reports the caller has
|
||||||
|
// some admin scope — a non-admin sees nothing, which keeps the chrome
|
||||||
|
// quiet for the common case. The toggle fades in once access loads so
|
||||||
|
// non-admins never even see the affordance flash.
|
||||||
|
//
|
||||||
|
// Click flow: set/clear the cookie, then reload the page so the server
|
||||||
|
// sees the new state on the next render. The reload is intentional —
|
||||||
|
// admin scaffolds in tool HTML are server-rendered for some tools, so
|
||||||
|
// a soft state flip on the client alone wouldn't reach those.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (!window.zddc) window.zddc = {};
|
||||||
|
if (window.zddc.elevation) return;
|
||||||
|
|
||||||
|
var COOKIE_NAME = 'zddc-elevate';
|
||||||
|
|
||||||
|
function isElevated() {
|
||||||
|
var parts = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
var kv = parts[i].trim().split('=');
|
||||||
|
if (kv[0] === COOKIE_NAME && kv[1] === '1') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElevated(on) {
|
||||||
|
if (on) {
|
||||||
|
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
|
||||||
|
// shapes. Max-Age caps the elevation window so a forgotten
|
||||||
|
// tab doesn't leave admin powers active indefinitely (sudo's
|
||||||
|
// 5-minute precedent informs the number — 30 minutes is a
|
||||||
|
// reasonable trade between annoyance and exposure).
|
||||||
|
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
|
||||||
|
} else {
|
||||||
|
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccess() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/.profile/access', {
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-cache'
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return await resp.json();
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(host, elevated) {
|
||||||
|
host.classList.remove('hidden');
|
||||||
|
host.innerHTML =
|
||||||
|
'<input type="checkbox" id="elevation-checkbox"'
|
||||||
|
+ (elevated ? ' checked' : '') + '>'
|
||||||
|
+ '<label for="elevation-checkbox" class="elevation-toggle__label">'
|
||||||
|
+ 'Admin</label>';
|
||||||
|
var cb = host.querySelector('#elevation-checkbox');
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
setElevated(cb.checked);
|
||||||
|
// Hard reload so server-rendered admin surfaces (profile
|
||||||
|
// page scaffolds, hidden-entry listings) catch up. URL
|
||||||
|
// and scroll state are preserved by the browser's normal
|
||||||
|
// back-forward cache rules.
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-wide affordances when elevation is active. The toggle alone
|
||||||
|
// is easy to miss — admin mode silently bypasses WORM and ACL
|
||||||
|
// restrictions, which produces surprising "I shouldn't have been
|
||||||
|
// able to do that" moments. A body class + a sticky banner with a
|
||||||
|
// one-click disable make the armed state unmistakable.
|
||||||
|
function applyArmedChrome(elevated) {
|
||||||
|
var b = document.body;
|
||||||
|
if (!b) return;
|
||||||
|
if (elevated) b.classList.add('is-elevated');
|
||||||
|
else b.classList.remove('is-elevated');
|
||||||
|
|
||||||
|
var banner = document.getElementById('elevation-banner');
|
||||||
|
if (elevated) {
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'elevation-banner';
|
||||||
|
banner.className = 'elevation-banner';
|
||||||
|
banner.setAttribute('role', 'alert');
|
||||||
|
banner.innerHTML =
|
||||||
|
'<span class="elevation-banner__dot" aria-hidden="true"></span>'
|
||||||
|
+ '<span class="elevation-banner__msg">'
|
||||||
|
+ 'Admin mode is on — write access bypasses WORM and ACL safeguards.'
|
||||||
|
+ '</span>'
|
||||||
|
+ '<button type="button" class="elevation-banner__off" id="elevation-banner-off">'
|
||||||
|
+ 'Drop admin'
|
||||||
|
+ '</button>';
|
||||||
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
|
var off = banner.querySelector('#elevation-banner-off');
|
||||||
|
if (off) off.addEventListener('click', function () {
|
||||||
|
setElevated(false);
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (banner) {
|
||||||
|
banner.parentNode.removeChild(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
// Body chrome applies on every page load whether or not the
|
||||||
|
// header has a toggle slot — the banner needs to surface in
|
||||||
|
// tools / pages that don't host the toggle (e.g. iframed
|
||||||
|
// classifier inside browse's grid mode), so the user can't
|
||||||
|
// accidentally write through an elevated context elsewhere.
|
||||||
|
applyArmedChrome(isElevated());
|
||||||
|
|
||||||
|
var host = document.getElementById('elevation-toggle');
|
||||||
|
if (!host) return; // tool doesn't include the slot yet — no-op
|
||||||
|
var access = await fetchAccess();
|
||||||
|
if (!access) return; // anonymous / endpoint missing — no-op
|
||||||
|
// Surface ONLY for users who have admin authority somewhere.
|
||||||
|
// /.profile/access ships `can_elevate` as an elevation-
|
||||||
|
// INDEPENDENT signal — true for any user named in any admin
|
||||||
|
// list, regardless of current cookie state. The other flags
|
||||||
|
// (is_super_admin, has_any_admin_scope) reflect EFFECTIVE
|
||||||
|
// authority and would be false for an un-elevated admin
|
||||||
|
// who hasn't toggled yet — so we can't gate on those.
|
||||||
|
if (!access.can_elevate) return;
|
||||||
|
render(host, isElevated());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated };
|
||||||
|
})();
|
||||||
|
|
||||||
(function (app) {
|
(function (app) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
archive=v0.0.17
|
||||||
transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
transmittal=v0.0.17
|
||||||
classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
classifier=v0.0.17
|
||||||
landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
landing=v0.0.17
|
||||||
form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
form=v0.0.17
|
||||||
tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
tables=v0.0.17
|
||||||
browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552
|
browse=v0.0.17
|
||||||
|
|
|
||||||
|
|
@ -1515,7 +1515,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-19 13:42:33 · 1721b4b-dirty</span></span>
|
<span class="build-timestamp">v0.0.17</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -5350,7 +5350,23 @@ body.is-elevated::after {
|
||||||
// Success: clear drafts + invalid marks, capture new ETag.
|
// Success: clear drafts + invalid marks, capture new ETag.
|
||||||
const newEtag = resp.headers.get('ETag');
|
const newEtag = resp.headers.get('ETag');
|
||||||
if (newEtag) row.etag = newEtag.replace(/"/g, '');
|
if (newEtag) row.etag = newEtag.replace(/"/g, '');
|
||||||
row.data = merged;
|
// For record-typed writes the server echoes the stamped
|
||||||
|
// YAML (with server-managed audit fields) back as the
|
||||||
|
// response body — parse it and overwrite row.data so the
|
||||||
|
// table sees the same bytes that just landed on disk.
|
||||||
|
// Falls back to the local merge when the server didn't
|
||||||
|
// echo a body (non-record write or older server).
|
||||||
|
let serverData = null;
|
||||||
|
const ct = (resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
if (ct.includes('yaml') && window.jsyaml) {
|
||||||
|
try {
|
||||||
|
const text = await resp.text();
|
||||||
|
if (text && text.trim()) serverData = window.jsyaml.load(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[tables] server response YAML parse failed; using local merge', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.data = serverData || merged;
|
||||||
delete app.state.drafts[rowId];
|
delete app.state.drafts[rowId];
|
||||||
clearCellInvalid(rowId);
|
clearCellInvalid(rowId);
|
||||||
setRowState(rowId, '');
|
setRowState(rowId, '');
|
||||||
|
|
@ -6753,7 +6769,16 @@ body.is-elevated::after {
|
||||||
const help = (ui && ui['ui:help']) || '';
|
const help = (ui && ui['ui:help']) || '';
|
||||||
const placeholder = (ui && ui['ui:placeholder']) || '';
|
const placeholder = (ui && ui['ui:placeholder']) || '';
|
||||||
const widget = (ui && ui['ui:widget']) || '';
|
const widget = (ui && ui['ui:widget']) || '';
|
||||||
const readonly = !!(ui && ui['ui:readonly']);
|
// readonly is honored from either source: an explicit UI override
|
||||||
|
// (ui:readonly: true) or the schema's readOnly field. The latter
|
||||||
|
// is set by the server when augmenting from cascade-locked
|
||||||
|
// records: entries and for audit fields declared readOnly in the
|
||||||
|
// *.form.yaml.
|
||||||
|
const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']);
|
||||||
|
// x-labels: { code → label } turns a bare enum into a labeled
|
||||||
|
// dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected
|
||||||
|
// by the server from the cascade's field_codes:codes map.
|
||||||
|
const labels = (schema && schema['x-labels']) || null;
|
||||||
const autofocus = !!(ui && ui['ui:autofocus']);
|
const autofocus = !!(ui && ui['ui:autofocus']);
|
||||||
|
|
||||||
let input;
|
let input;
|
||||||
|
|
@ -6799,17 +6824,22 @@ body.is-elevated::after {
|
||||||
if (widget === 'radio') {
|
if (widget === 'radio') {
|
||||||
input = u.h('div', { className: 'form-field__radio-group' });
|
input = u.h('div', { className: 'form-field__radio-group' });
|
||||||
opts.forEach(function (opt, idx) {
|
opts.forEach(function (opt, idx) {
|
||||||
|
const codeStr = String(opt);
|
||||||
const radioId = id + '-' + idx;
|
const radioId = id + '-' + idx;
|
||||||
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) });
|
const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr });
|
||||||
if (value === opt) {
|
if (value === opt) {
|
||||||
radio.checked = true;
|
radio.checked = true;
|
||||||
}
|
}
|
||||||
if (readonly) {
|
if (readonly) {
|
||||||
radio.disabled = true;
|
radio.disabled = true;
|
||||||
}
|
}
|
||||||
|
let displayText = codeStr;
|
||||||
|
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
|
||||||
|
displayText = codeStr + ' — ' + labels[codeStr];
|
||||||
|
}
|
||||||
const lbl = u.h('label', { for: radioId });
|
const lbl = u.h('label', { for: radioId });
|
||||||
lbl.appendChild(radio);
|
lbl.appendChild(radio);
|
||||||
lbl.appendChild(document.createTextNode(' ' + String(opt)));
|
lbl.appendChild(document.createTextNode(' ' + displayText));
|
||||||
input.appendChild(lbl);
|
input.appendChild(lbl);
|
||||||
});
|
});
|
||||||
read = function () {
|
read = function () {
|
||||||
|
|
@ -6822,7 +6852,12 @@ body.is-elevated::after {
|
||||||
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
input.appendChild(u.h('option', { value: '' }, '— select —'));
|
||||||
}
|
}
|
||||||
opts.forEach(function (opt) {
|
opts.forEach(function (opt) {
|
||||||
const o = u.h('option', { value: String(opt) }, String(opt));
|
const codeStr = String(opt);
|
||||||
|
let displayText = codeStr;
|
||||||
|
if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) {
|
||||||
|
displayText = codeStr + ' — ' + labels[codeStr];
|
||||||
|
}
|
||||||
|
const o = u.h('option', { value: codeStr }, displayText);
|
||||||
if (value === opt) {
|
if (value === opt) {
|
||||||
o.selected = true;
|
o.selected = true;
|
||||||
}
|
}
|
||||||
|
|
@ -6893,6 +6928,12 @@ body.is-elevated::after {
|
||||||
if (autofocus) {
|
if (autofocus) {
|
||||||
input.autofocus = true;
|
input.autofocus = true;
|
||||||
}
|
}
|
||||||
|
// Schema-driven HTML pattern attribute. Used as a UX hint
|
||||||
|
// only — authoritative validation runs server-side via the
|
||||||
|
// cascade's field_codes.
|
||||||
|
if (schema.pattern && input.tagName === 'INPUT') {
|
||||||
|
input.pattern = schema.pattern;
|
||||||
|
}
|
||||||
read = function () {
|
read = function () {
|
||||||
return input.value === '' ? undefined : input.value;
|
return input.value === '' ? undefined : input.value;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue