chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s

This commit is contained in:
ZDDC 2026-06-08 06:50:27 -05:00
parent 0d7feb3468
commit d5ce4e1230
7 changed files with 2668 additions and 660 deletions

View file

@ -74,6 +74,20 @@
/* Shape */ /* Shape */
--radius: 4px; --radius: 4px;
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
Were undefined (var() with no fallback → collapsed to 0), which left
table cells unpadded and the table flush to the viewport edges. */
--spacing-sm: 0.4rem;
--spacing-md: 0.8rem;
--spacing-lg: 1.5rem;
/* Token aliases the tables tool references under --color-*/--radius-*
names; map them to the canonical tokens (themed values flow through). */
--color-text-muted: var(--text-muted);
--color-border: var(--border);
--color-bg-elevated: var(--bg-secondary);
--radius-sm: var(--radius);
/* Typography. --font-display covers headings (Source Serif 4 — a refined /* Typography. --font-display covers headings (Source Serif 4 — a refined
transitional serif that reads as "engineering / document / serious" transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans — without being academic). --font is body UI text (IBM Plex Sans —
@ -855,53 +869,10 @@ body.help-open .app-header {
filter: brightness(1.1); filter: brightness(1.1);
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — page-wide armed chrome for admin mode.
Renders only for users with admin scope (handled by elevation.js; The elevate CONTROL is the "Admin mode" item in the shared profile menu
the placeholder is `.hidden` by default). When visible, sits left (shared/profile-menu.{js,css}); this file only styles the unmistakable
of the theme button — sudo-style affordance for opting into admin "you are elevated" cues: the red viewport frame + the sticky banner. */
powers. */
.elevation-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is /* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue: easy to miss; these add an inescapable visual cue:
@ -978,6 +949,118 @@ body.is-elevated::after {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* shared/profile-menu.css — header account menu (upper-right).
shared/profile-menu.js mounts a button into `.header-right` and toggles
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
and Sign out. Server mode only. */
.profile-menu {
position: relative;
display: inline-flex;
}
/* The button: a small circular avatar showing the email initial. */
.profile-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
line-height: 1;
}
.profile-btn__avatar {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
}
/* Armed (admin mode on): a red ring so the elevated state reads from the
button even when the menu is closed — pairs with the page banner/frame. */
.profile-btn--armed {
box-shadow: 0 0 0 2px var(--danger, #dc3545);
border-color: var(--danger, #dc3545);
color: var(--danger, #dc3545);
}
.profile-menu__panel {
display: none;
/* Fixed + JS-positioned from the button rect: an absolute panel gets
trapped below the content layer by the app's stacking contexts, so
anchor it to the viewport instead (profile-menu.js sets top/right). */
position: fixed;
min-width: 15rem;
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
background: var(--bg, #fff);
border: 1px solid var(--border, #ddd);
border-radius: var(--radius, 6px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
padding: 0.3rem;
font-size: 0.85rem;
}
.profile-menu__panel.open { display: block; }
.profile-menu__id {
padding: 0.35rem 0.55rem 0.45rem;
}
.profile-menu__email {
font-weight: 600;
color: var(--text, #222);
word-break: break-all;
}
.profile-menu__role {
margin-top: 0.1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--danger, #dc3545);
}
.profile-menu__sep {
height: 1px;
margin: 0.25rem 0;
background: var(--border, #eee);
}
.profile-menu__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.55rem;
border-radius: var(--radius, 4px);
color: var(--text, #222);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
text-align: left;
font: inherit;
}
.profile-menu__item:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
}
.profile-menu__toggle { cursor: pointer; }
.profile-menu__check {
margin: 0;
cursor: pointer;
accent-color: var(--danger, #dc3545);
flex-shrink: 0;
}
.profile-menu__toggle-label {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.profile-menu__hint {
font-size: 0.72rem;
color: var(--text-muted, #888);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */ so it reads as clickable without altering the logo's visual weight. */
@ -2582,18 +2665,12 @@ 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.27-beta · 2026-06-05 12:41:17 · 382645b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use 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">
<!-- 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">?</button> <button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div> </div>
@ -10855,26 +10932,31 @@ window.app.modules.filtering = {
} }
}()); }());
// shared/elevation.js — admin elevation via URL toggle. // shared/elevation.js — admin elevation state machine.
// //
// Sudo-style model: admins behave as normal users by default; elevating // Sudo-style model: admins behave as normal users by default; elevating
// the session turns on admin escape hatches (WORM bypass, .zddc edit // the session turns on admin escape hatches (WORM bypass, recursive
// authority, profile admin scaffolds). State is carried in a // delete, rearranging records, profile admin scaffolds — NOT config-edit,
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware // which is standing). State is carried in a `zddc-elevate=1` cookie that
// → zddc.Principal{Elevated}. // the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
// //
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false` // This module owns the STATE (cookie, armed chrome/banner, ephemeral
// (or the red banner's "Drop admin" button) to drop — so it's reachable // lifecycle, the change event) + exposes setOn/setOff/isElevated. The
// from ANY zddc-server page, not just ones that render a header control. // on-page elevate CONTROL lives in the shared profile menu
// The cookie is the sticky state: it persists across navigation for its // (shared/profile-menu.js) — an "Admin mode" item shown only to
// Max-Age window, so the param need not stay in the URL (we strip it). // can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
// Arming is gated on /.profile/access `can_elevate`, so only real admins // into any URL is also honoured (gated on can_elevate), for deep links /
// can set it; a non-admin's ?admin=true is a silent no-op. // scripting.
// //
// Applying the cookie reloads to the cleaned URL so the server re-renders // Admin mode is EPHEMERAL — scoped to the page you turned it on:
// under the new state (admin scaffolds in some tool HTML are server- // * the cookie is a SESSION cookie (no Max-Age), and
// rendered, so a client-only flip wouldn't reach them). The red viewport // * we clear it on `pagehide`, so navigating away / closing the tab
// border + banner (applyArmedChrome) reflect the cookie on every load. // drops admin (you re-arm deliberately on the next page).
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
// would race the clear). SPAs that server-render elevation-dependent data
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
// event we emit and re-fetch. The red viewport border + banner
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
(function () { (function () {
'use strict'; 'use strict';
@ -10895,16 +10977,43 @@ window.app.modules.filtering = {
function setElevated(on) { function setElevated(on) {
if (on) { if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF // SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten // shapes. No Max-Age → a SESSION cookie: it dies with the tab
// tab doesn't leave admin powers active indefinitely (sudo's // and, combined with the pagehide handler below, is cleared the
// 5-minute precedent informs the number — 30 minutes is a // moment you leave the page. Admin powers never silently
// reasonable trade between annoyance and exposure). // outlive the page you armed them on.
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800'; document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else { } else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0'; document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
} }
} }
// emitChange notifies same-page listeners (SPAs that server-render
// elevation-dependent data, e.g. browse's listing verbs / editor
// affordances) so they can re-fetch without a full reload.
function emitChange() {
try {
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
detail: { elevated: isElevated() }
}));
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
}
// setOn / setOff are the single funnel for every arm/drop path (the
// profile menu's Admin mode item, the ?admin= URL param, the banner's
// Drop button). Each flips the cookie, re-paints the armed chrome, and
// emits the change — no reload. The profile menu listens for the change
// event to keep its checkbox + armed indicator in sync.
function setOn() {
setElevated(true);
applyArmedChrome(true);
emitChange();
}
function setOff() {
setElevated(false);
applyArmedChrome(false);
emitChange();
}
async function fetchAccess() { async function fetchAccess() {
try { try {
var resp = await fetch('/.profile/access', { var resp = await fetch('/.profile/access', {
@ -10950,34 +11059,26 @@ window.app.modules.filtering = {
return u.pathname + (qs ? '?' + qs : '') + u.hash; return u.pathname + (qs ? '?' + qs : '') + u.hash;
} }
// handleAdminParam applies a ?admin= request. Returns true when a // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
// navigation (reload) is underway so the caller can stop. Enabling is // the module header on why reloads would race the pagehide-clear).
// gated on can_elevate — a non-admin who types ?admin=true just gets // Enabling is gated on can_elevate — a non-admin who types ?admin=true
// the param stripped, never a misleading red border. Disabling is open // just gets the param stripped, never a misleading red border.
// (anyone may drop a cookie they somehow hold). // Disabling is open (anyone may drop a cookie they somehow hold).
async function handleAdminParam() { // `access` (a prefetched /.profile/access, may be null) lets init reuse
// its single fetch instead of issuing a second one.
async function handleAdminParam(access) {
var want = adminParam(); var want = adminParam();
if (want === null) return false; if (want === null) return;
var clean = urlWithoutAdmin(); var clean = urlWithoutAdmin();
if (want === isElevated()) { try { history.replaceState(history.state, '', clean); } catch (_e) {}
// Already in the requested state — just clean the URL, no reload. if (want === isElevated()) return; // already in the requested state
try { history.replaceState(history.state, '', clean); } catch (_e) {}
return false;
}
if (want === true) { if (want === true) {
var access = await fetchAccess(); if (access === undefined) access = await fetchAccess();
if (!access || !access.can_elevate) { if (!access || !access.can_elevate) return; // silent no-op
try { history.replaceState(history.state, '', clean); } catch (_e) {} setOn();
return false;
}
setElevated(true);
} else { } else {
setElevated(false); setOff();
} }
// Navigate to the clean URL (a real load, so the server re-renders
// under the new cookie) and replace history so Back is safe.
window.location.replace(clean);
return true;
} }
// Page-wide affordances when elevation is active. The toggle alone // Page-wide affordances when elevation is active. The toggle alone
@ -11008,10 +11109,7 @@ window.app.modules.filtering = {
+ '</button>'; + '</button>';
document.body.insertBefore(banner, document.body.firstChild); document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off'); var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () { if (off) off.addEventListener('click', function () { setOff(); });
setElevated(false);
window.location.reload();
});
} }
} else if (banner) { } else if (banner) {
banner.parentNode.removeChild(banner); banner.parentNode.removeChild(banner);
@ -11019,16 +11117,30 @@ window.app.modules.filtering = {
} }
async function init() { async function init() {
// Apply (or tear down) the red border + banner from the cookie on // file:// (offline FS-Access mode) has no server to elevate against.
// every page load — admin mode is toggled by URL, but the armed if (window.location.protocol === 'file:') return;
// chrome must surface everywhere so the user can't accidentally
// write through an elevated context on a page they didn't toggle. // Reflect the cookie's armed chrome on every load (a leftover from a
// not-yet-fired pagehide, or an arrived-with ?admin link).
applyArmedChrome(isElevated()); applyArmedChrome(isElevated());
// Honour ?admin=true|false typed into any zddc-server URL. There's // Honour ?admin=true|false typed into any URL — handleAdminParam
// no on-screen toggle anymore — the URL is the enable path and the // fetches /.profile/access itself to gate arming on can_elevate. The
// red banner's "Drop admin" button is the one-click disable. // on-page elevate control lives in the shared profile menu
// (shared/profile-menu.js), which calls setOn/setOff and listens for
// zddc:elevationchange to keep its checkbox + armed ring in sync.
await handleAdminParam(); await handleAdminParam();
// Admin mode is per-page: clear the cookie when the page goes away so
// it never persists past a navigation.
window.addEventListener('pagehide', function () {
if (isElevated()) setElevated(false);
});
// bfcache can restore a page whose pagehide already cleared the
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
window.addEventListener('pageshow', function (e) {
if (e.persisted) applyArmedChrome(isElevated());
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@ -11037,7 +11149,178 @@ window.app.modules.filtering = {
init(); init();
} }
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = {
isElevated: isElevated,
setElevated: setElevated,
setOn: setOn,
setOff: setOff
};
})();
// shared/profile-menu.js — account menu in the header's upper-right.
//
// Replaces the old floating elevation toggle. Admin mode is now one item in
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
// / ephemeral state machine stays in shared/elevation.js.
//
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
//
// Server mode only: it reads /.profile/access for the email + can_elevate.
// On file:// (offline FS-Access mode) there's no server account, so nothing
// renders.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.profileMenu) return;
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
async function fetchAccess() {
try {
var r = await fetch('/.profile/access', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!r.ok) return null;
return await r.json();
} catch (_e) { return null; }
}
var elevation = null;
var panelEl = null, btnEl = null, adminInput = null;
function isElevated() {
return !!(elevation && elevation.isElevated && elevation.isElevated());
}
// Keep the button's armed ring + the menu checkbox in lockstep with the
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
function syncArmed() {
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
if (adminInput) adminInput.checked = isElevated();
}
function closeMenu() {
if (panelEl) panelEl.classList.remove('open');
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
}
// The panel is position:fixed (to escape the app's stacking contexts), so
// anchor it to the button rect — top just below it, right-aligned.
function positionPanel() {
var r = btnEl.getBoundingClientRect();
panelEl.style.top = (r.bottom + 4) + 'px';
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
panelEl.style.left = 'auto';
}
function toggleMenu() {
if (!panelEl) return;
var open = panelEl.classList.toggle('open');
if (open) positionPanel();
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function linkItem(text, href) {
var a = el('a', 'profile-menu__item', text);
a.href = href;
a.setAttribute('role', 'menuitem');
return a;
}
function build(access) {
var wrap = el('div', 'profile-menu');
btnEl = el('button', 'btn btn-secondary profile-btn');
btnEl.type = 'button';
btnEl.id = 'profile-btn';
btnEl.title = 'Account: ' + (access.email || 'signed in');
btnEl.setAttribute('aria-haspopup', 'menu');
btnEl.setAttribute('aria-expanded', 'false');
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
wrap.appendChild(btnEl);
panelEl = el('div', 'profile-menu__panel');
panelEl.setAttribute('role', 'menu');
var id = el('div', 'profile-menu__id');
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
panelEl.appendChild(id);
panelEl.appendChild(el('div', 'profile-menu__sep'));
// Admin mode — only offered to principals who actually have admin
// authority somewhere (can_elevate). Drops automatically on leave.
if (access.can_elevate && elevation) {
var row = el('label', 'profile-menu__item profile-menu__toggle');
adminInput = document.createElement('input');
adminInput.type = 'checkbox';
adminInput.className = 'profile-menu__check';
adminInput.checked = isElevated();
adminInput.addEventListener('change', function () {
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
});
row.appendChild(adminInput);
var txt = el('span', 'profile-menu__toggle-label');
txt.appendChild(el('span', null, 'Admin mode'));
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
row.appendChild(txt);
panelEl.appendChild(row);
panelEl.appendChild(el('div', 'profile-menu__sep'));
}
panelEl.appendChild(linkItem('Profile', '/.profile'));
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
// No "Sign out": authentication is the upstream proxy's concern
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
// doesn't render a logout affordance.
// Portal the panel to <body>, not inside the header: the app's
// layout creates stacking contexts that trap even a fixed+high
// z-index panel below the content. As a direct body child it sits in
// the root stacking context and reliably overlays everything.
// position:fixed + positionPanel() keep it anchored to the button.
document.body.appendChild(panelEl);
return wrap;
}
async function init() {
if (window.location.protocol === 'file:') return;
elevation = window.zddc.elevation || null;
var access = await fetchAccess();
if (!access || !access.email) return; // unauthenticated / non-zddc backend
var host = document.querySelector('.header-right');
if (!host) return;
host.appendChild(build(access));
syncArmed();
document.addEventListener('click', function (e) {
if (panelEl && panelEl.classList.contains('open')
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
window.addEventListener('zddc:elevationchange', syncArmed);
window.zddc.profileMenu = { close: closeMenu };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
// shared/cap.js — client-side capability helpers for permission gating. // shared/cap.js — client-side capability helpers for permission gating.

File diff suppressed because it is too large Load diff

View file

@ -74,6 +74,20 @@
/* Shape */ /* Shape */
--radius: 4px; --radius: 4px;
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
Were undefined (var() with no fallback → collapsed to 0), which left
table cells unpadded and the table flush to the viewport edges. */
--spacing-sm: 0.4rem;
--spacing-md: 0.8rem;
--spacing-lg: 1.5rem;
/* Token aliases the tables tool references under --color-*/--radius-*
names; map them to the canonical tokens (themed values flow through). */
--color-text-muted: var(--text-muted);
--color-border: var(--border);
--color-bg-elevated: var(--bg-secondary);
--radius-sm: var(--radius);
/* Typography. --font-display covers headings (Source Serif 4 — a refined /* Typography. --font-display covers headings (Source Serif 4 — a refined
transitional serif that reads as "engineering / document / serious" transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans — without being academic). --font is body UI text (IBM Plex Sans —
@ -855,53 +869,10 @@ body.help-open .app-header {
filter: brightness(1.1); filter: brightness(1.1);
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — page-wide armed chrome for admin mode.
Renders only for users with admin scope (handled by elevation.js; The elevate CONTROL is the "Admin mode" item in the shared profile menu
the placeholder is `.hidden` by default). When visible, sits left (shared/profile-menu.{js,css}); this file only styles the unmistakable
of the theme button — sudo-style affordance for opting into admin "you are elevated" cues: the red viewport frame + the sticky banner. */
powers. */
.elevation-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is /* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue: easy to miss; these add an inescapable visual cue:
@ -978,6 +949,118 @@ body.is-elevated::after {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* shared/profile-menu.css — header account menu (upper-right).
shared/profile-menu.js mounts a button into `.header-right` and toggles
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
and Sign out. Server mode only. */
.profile-menu {
position: relative;
display: inline-flex;
}
/* The button: a small circular avatar showing the email initial. */
.profile-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
line-height: 1;
}
.profile-btn__avatar {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
}
/* Armed (admin mode on): a red ring so the elevated state reads from the
button even when the menu is closed — pairs with the page banner/frame. */
.profile-btn--armed {
box-shadow: 0 0 0 2px var(--danger, #dc3545);
border-color: var(--danger, #dc3545);
color: var(--danger, #dc3545);
}
.profile-menu__panel {
display: none;
/* Fixed + JS-positioned from the button rect: an absolute panel gets
trapped below the content layer by the app's stacking contexts, so
anchor it to the viewport instead (profile-menu.js sets top/right). */
position: fixed;
min-width: 15rem;
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
background: var(--bg, #fff);
border: 1px solid var(--border, #ddd);
border-radius: var(--radius, 6px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
padding: 0.3rem;
font-size: 0.85rem;
}
.profile-menu__panel.open { display: block; }
.profile-menu__id {
padding: 0.35rem 0.55rem 0.45rem;
}
.profile-menu__email {
font-weight: 600;
color: var(--text, #222);
word-break: break-all;
}
.profile-menu__role {
margin-top: 0.1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--danger, #dc3545);
}
.profile-menu__sep {
height: 1px;
margin: 0.25rem 0;
background: var(--border, #eee);
}
.profile-menu__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.55rem;
border-radius: var(--radius, 4px);
color: var(--text, #222);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
text-align: left;
font: inherit;
}
.profile-menu__item:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
}
.profile-menu__toggle { cursor: pointer; }
.profile-menu__check {
margin: 0;
cursor: pointer;
accent-color: var(--danger, #dc3545);
flex-shrink: 0;
}
.profile-menu__toggle-label {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.profile-menu__hint {
font-size: 0.72rem;
color: var(--text-muted, #888);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */ so it reads as clickable without altering the logo's visual weight. */
@ -1793,18 +1876,12 @@ body.is-elevated::after {
</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.27-beta · 2026-06-05 12:41:17 · 382645b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use 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">
<!-- 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">?</button> <button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div> </div>
@ -9979,26 +10056,31 @@ X.B(E,Y);return E}return J}())
} }
}()); }());
// shared/elevation.js — admin elevation via URL toggle. // shared/elevation.js — admin elevation state machine.
// //
// Sudo-style model: admins behave as normal users by default; elevating // Sudo-style model: admins behave as normal users by default; elevating
// the session turns on admin escape hatches (WORM bypass, .zddc edit // the session turns on admin escape hatches (WORM bypass, recursive
// authority, profile admin scaffolds). State is carried in a // delete, rearranging records, profile admin scaffolds — NOT config-edit,
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware // which is standing). State is carried in a `zddc-elevate=1` cookie that
// → zddc.Principal{Elevated}. // the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
// //
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false` // This module owns the STATE (cookie, armed chrome/banner, ephemeral
// (or the red banner's "Drop admin" button) to drop — so it's reachable // lifecycle, the change event) + exposes setOn/setOff/isElevated. The
// from ANY zddc-server page, not just ones that render a header control. // on-page elevate CONTROL lives in the shared profile menu
// The cookie is the sticky state: it persists across navigation for its // (shared/profile-menu.js) — an "Admin mode" item shown only to
// Max-Age window, so the param need not stay in the URL (we strip it). // can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
// Arming is gated on /.profile/access `can_elevate`, so only real admins // into any URL is also honoured (gated on can_elevate), for deep links /
// can set it; a non-admin's ?admin=true is a silent no-op. // scripting.
// //
// Applying the cookie reloads to the cleaned URL so the server re-renders // Admin mode is EPHEMERAL — scoped to the page you turned it on:
// under the new state (admin scaffolds in some tool HTML are server- // * the cookie is a SESSION cookie (no Max-Age), and
// rendered, so a client-only flip wouldn't reach them). The red viewport // * we clear it on `pagehide`, so navigating away / closing the tab
// border + banner (applyArmedChrome) reflect the cookie on every load. // drops admin (you re-arm deliberately on the next page).
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
// would race the clear). SPAs that server-render elevation-dependent data
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
// event we emit and re-fetch. The red viewport border + banner
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
(function () { (function () {
'use strict'; 'use strict';
@ -10019,16 +10101,43 @@ X.B(E,Y);return E}return J}())
function setElevated(on) { function setElevated(on) {
if (on) { if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF // SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten // shapes. No Max-Age → a SESSION cookie: it dies with the tab
// tab doesn't leave admin powers active indefinitely (sudo's // and, combined with the pagehide handler below, is cleared the
// 5-minute precedent informs the number — 30 minutes is a // moment you leave the page. Admin powers never silently
// reasonable trade between annoyance and exposure). // outlive the page you armed them on.
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800'; document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else { } else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0'; document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
} }
} }
// emitChange notifies same-page listeners (SPAs that server-render
// elevation-dependent data, e.g. browse's listing verbs / editor
// affordances) so they can re-fetch without a full reload.
function emitChange() {
try {
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
detail: { elevated: isElevated() }
}));
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
}
// setOn / setOff are the single funnel for every arm/drop path (the
// profile menu's Admin mode item, the ?admin= URL param, the banner's
// Drop button). Each flips the cookie, re-paints the armed chrome, and
// emits the change — no reload. The profile menu listens for the change
// event to keep its checkbox + armed indicator in sync.
function setOn() {
setElevated(true);
applyArmedChrome(true);
emitChange();
}
function setOff() {
setElevated(false);
applyArmedChrome(false);
emitChange();
}
async function fetchAccess() { async function fetchAccess() {
try { try {
var resp = await fetch('/.profile/access', { var resp = await fetch('/.profile/access', {
@ -10074,34 +10183,26 @@ X.B(E,Y);return E}return J}())
return u.pathname + (qs ? '?' + qs : '') + u.hash; return u.pathname + (qs ? '?' + qs : '') + u.hash;
} }
// handleAdminParam applies a ?admin= request. Returns true when a // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
// navigation (reload) is underway so the caller can stop. Enabling is // the module header on why reloads would race the pagehide-clear).
// gated on can_elevate — a non-admin who types ?admin=true just gets // Enabling is gated on can_elevate — a non-admin who types ?admin=true
// the param stripped, never a misleading red border. Disabling is open // just gets the param stripped, never a misleading red border.
// (anyone may drop a cookie they somehow hold). // Disabling is open (anyone may drop a cookie they somehow hold).
async function handleAdminParam() { // `access` (a prefetched /.profile/access, may be null) lets init reuse
// its single fetch instead of issuing a second one.
async function handleAdminParam(access) {
var want = adminParam(); var want = adminParam();
if (want === null) return false; if (want === null) return;
var clean = urlWithoutAdmin(); var clean = urlWithoutAdmin();
if (want === isElevated()) { try { history.replaceState(history.state, '', clean); } catch (_e) {}
// Already in the requested state — just clean the URL, no reload. if (want === isElevated()) return; // already in the requested state
try { history.replaceState(history.state, '', clean); } catch (_e) {}
return false;
}
if (want === true) { if (want === true) {
var access = await fetchAccess(); if (access === undefined) access = await fetchAccess();
if (!access || !access.can_elevate) { if (!access || !access.can_elevate) return; // silent no-op
try { history.replaceState(history.state, '', clean); } catch (_e) {} setOn();
return false;
}
setElevated(true);
} else { } else {
setElevated(false); setOff();
} }
// Navigate to the clean URL (a real load, so the server re-renders
// under the new cookie) and replace history so Back is safe.
window.location.replace(clean);
return true;
} }
// Page-wide affordances when elevation is active. The toggle alone // Page-wide affordances when elevation is active. The toggle alone
@ -10132,10 +10233,7 @@ X.B(E,Y);return E}return J}())
+ '</button>'; + '</button>';
document.body.insertBefore(banner, document.body.firstChild); document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off'); var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () { if (off) off.addEventListener('click', function () { setOff(); });
setElevated(false);
window.location.reload();
});
} }
} else if (banner) { } else if (banner) {
banner.parentNode.removeChild(banner); banner.parentNode.removeChild(banner);
@ -10143,16 +10241,30 @@ X.B(E,Y);return E}return J}())
} }
async function init() { async function init() {
// Apply (or tear down) the red border + banner from the cookie on // file:// (offline FS-Access mode) has no server to elevate against.
// every page load — admin mode is toggled by URL, but the armed if (window.location.protocol === 'file:') return;
// chrome must surface everywhere so the user can't accidentally
// write through an elevated context on a page they didn't toggle. // Reflect the cookie's armed chrome on every load (a leftover from a
// not-yet-fired pagehide, or an arrived-with ?admin link).
applyArmedChrome(isElevated()); applyArmedChrome(isElevated());
// Honour ?admin=true|false typed into any zddc-server URL. There's // Honour ?admin=true|false typed into any URL — handleAdminParam
// no on-screen toggle anymore — the URL is the enable path and the // fetches /.profile/access itself to gate arming on can_elevate. The
// red banner's "Drop admin" button is the one-click disable. // on-page elevate control lives in the shared profile menu
// (shared/profile-menu.js), which calls setOn/setOff and listens for
// zddc:elevationchange to keep its checkbox + armed ring in sync.
await handleAdminParam(); await handleAdminParam();
// Admin mode is per-page: clear the cookie when the page goes away so
// it never persists past a navigation.
window.addEventListener('pagehide', function () {
if (isElevated()) setElevated(false);
});
// bfcache can restore a page whose pagehide already cleared the
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
window.addEventListener('pageshow', function (e) {
if (e.persisted) applyArmedChrome(isElevated());
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@ -10161,7 +10273,178 @@ X.B(E,Y);return E}return J}())
init(); init();
} }
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = {
isElevated: isElevated,
setElevated: setElevated,
setOn: setOn,
setOff: setOff
};
})();
// shared/profile-menu.js — account menu in the header's upper-right.
//
// Replaces the old floating elevation toggle. Admin mode is now one item in
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
// / ephemeral state machine stays in shared/elevation.js.
//
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
//
// Server mode only: it reads /.profile/access for the email + can_elevate.
// On file:// (offline FS-Access mode) there's no server account, so nothing
// renders.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.profileMenu) return;
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
async function fetchAccess() {
try {
var r = await fetch('/.profile/access', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!r.ok) return null;
return await r.json();
} catch (_e) { return null; }
}
var elevation = null;
var panelEl = null, btnEl = null, adminInput = null;
function isElevated() {
return !!(elevation && elevation.isElevated && elevation.isElevated());
}
// Keep the button's armed ring + the menu checkbox in lockstep with the
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
function syncArmed() {
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
if (adminInput) adminInput.checked = isElevated();
}
function closeMenu() {
if (panelEl) panelEl.classList.remove('open');
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
}
// The panel is position:fixed (to escape the app's stacking contexts), so
// anchor it to the button rect — top just below it, right-aligned.
function positionPanel() {
var r = btnEl.getBoundingClientRect();
panelEl.style.top = (r.bottom + 4) + 'px';
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
panelEl.style.left = 'auto';
}
function toggleMenu() {
if (!panelEl) return;
var open = panelEl.classList.toggle('open');
if (open) positionPanel();
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function linkItem(text, href) {
var a = el('a', 'profile-menu__item', text);
a.href = href;
a.setAttribute('role', 'menuitem');
return a;
}
function build(access) {
var wrap = el('div', 'profile-menu');
btnEl = el('button', 'btn btn-secondary profile-btn');
btnEl.type = 'button';
btnEl.id = 'profile-btn';
btnEl.title = 'Account: ' + (access.email || 'signed in');
btnEl.setAttribute('aria-haspopup', 'menu');
btnEl.setAttribute('aria-expanded', 'false');
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
wrap.appendChild(btnEl);
panelEl = el('div', 'profile-menu__panel');
panelEl.setAttribute('role', 'menu');
var id = el('div', 'profile-menu__id');
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
panelEl.appendChild(id);
panelEl.appendChild(el('div', 'profile-menu__sep'));
// Admin mode — only offered to principals who actually have admin
// authority somewhere (can_elevate). Drops automatically on leave.
if (access.can_elevate && elevation) {
var row = el('label', 'profile-menu__item profile-menu__toggle');
adminInput = document.createElement('input');
adminInput.type = 'checkbox';
adminInput.className = 'profile-menu__check';
adminInput.checked = isElevated();
adminInput.addEventListener('change', function () {
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
});
row.appendChild(adminInput);
var txt = el('span', 'profile-menu__toggle-label');
txt.appendChild(el('span', null, 'Admin mode'));
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
row.appendChild(txt);
panelEl.appendChild(row);
panelEl.appendChild(el('div', 'profile-menu__sep'));
}
panelEl.appendChild(linkItem('Profile', '/.profile'));
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
// No "Sign out": authentication is the upstream proxy's concern
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
// doesn't render a logout affordance.
// Portal the panel to <body>, not inside the header: the app's
// layout creates stacking contexts that trap even a fixed+high
// z-index panel below the content. As a direct body child it sits in
// the root stacking context and reliably overlays everything.
// position:fixed + positionPanel() keep it anchored to the button.
document.body.appendChild(panelEl);
return wrap;
}
async function init() {
if (window.location.protocol === 'file:') return;
elevation = window.zddc.elevation || null;
var access = await fetchAccess();
if (!access || !access.email) return; // unauthenticated / non-zddc backend
var host = document.querySelector('.header-right');
if (!host) return;
host.appendChild(build(access));
syncArmed();
document.addEventListener('click', function (e) {
if (panelEl && panelEl.classList.contains('open')
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
window.addEventListener('zddc:elevationchange', syncArmed);
window.zddc.profileMenu = { close: closeMenu };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
// shared/cap.js — client-side capability helpers for permission gating. // shared/cap.js — client-side capability helpers for permission gating.

View file

@ -74,6 +74,20 @@
/* Shape */ /* Shape */
--radius: 4px; --radius: 4px;
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
Were undefined (var() with no fallback → collapsed to 0), which left
table cells unpadded and the table flush to the viewport edges. */
--spacing-sm: 0.4rem;
--spacing-md: 0.8rem;
--spacing-lg: 1.5rem;
/* Token aliases the tables tool references under --color-*/--radius-*
names; map them to the canonical tokens (themed values flow through). */
--color-text-muted: var(--text-muted);
--color-border: var(--border);
--color-bg-elevated: var(--bg-secondary);
--radius-sm: var(--radius);
/* Typography. --font-display covers headings (Source Serif 4 — a refined /* Typography. --font-display covers headings (Source Serif 4 — a refined
transitional serif that reads as "engineering / document / serious" transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans — without being academic). --font is body UI text (IBM Plex Sans —
@ -855,53 +869,10 @@ body.help-open .app-header {
filter: brightness(1.1); filter: brightness(1.1);
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — page-wide armed chrome for admin mode.
Renders only for users with admin scope (handled by elevation.js; The elevate CONTROL is the "Admin mode" item in the shared profile menu
the placeholder is `.hidden` by default). When visible, sits left (shared/profile-menu.{js,css}); this file only styles the unmistakable
of the theme button — sudo-style affordance for opting into admin "you are elevated" cues: the red viewport frame + the sticky banner. */
powers. */
.elevation-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is /* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue: easy to miss; these add an inescapable visual cue:
@ -978,6 +949,118 @@ body.is-elevated::after {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* shared/profile-menu.css — header account menu (upper-right).
shared/profile-menu.js mounts a button into `.header-right` and toggles
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
and Sign out. Server mode only. */
.profile-menu {
position: relative;
display: inline-flex;
}
/* The button: a small circular avatar showing the email initial. */
.profile-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
line-height: 1;
}
.profile-btn__avatar {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
}
/* Armed (admin mode on): a red ring so the elevated state reads from the
button even when the menu is closed — pairs with the page banner/frame. */
.profile-btn--armed {
box-shadow: 0 0 0 2px var(--danger, #dc3545);
border-color: var(--danger, #dc3545);
color: var(--danger, #dc3545);
}
.profile-menu__panel {
display: none;
/* Fixed + JS-positioned from the button rect: an absolute panel gets
trapped below the content layer by the app's stacking contexts, so
anchor it to the viewport instead (profile-menu.js sets top/right). */
position: fixed;
min-width: 15rem;
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
background: var(--bg, #fff);
border: 1px solid var(--border, #ddd);
border-radius: var(--radius, 6px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
padding: 0.3rem;
font-size: 0.85rem;
}
.profile-menu__panel.open { display: block; }
.profile-menu__id {
padding: 0.35rem 0.55rem 0.45rem;
}
.profile-menu__email {
font-weight: 600;
color: var(--text, #222);
word-break: break-all;
}
.profile-menu__role {
margin-top: 0.1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--danger, #dc3545);
}
.profile-menu__sep {
height: 1px;
margin: 0.25rem 0;
background: var(--border, #eee);
}
.profile-menu__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.55rem;
border-radius: var(--radius, 4px);
color: var(--text, #222);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
text-align: left;
font: inherit;
}
.profile-menu__item:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
}
.profile-menu__toggle { cursor: pointer; }
.profile-menu__check {
margin: 0;
cursor: pointer;
accent-color: var(--danger, #dc3545);
flex-shrink: 0;
}
.profile-menu__toggle-label {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.profile-menu__hint {
font-size: 0.72rem;
color: var(--text-muted, #888);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */ so it reads as clickable without altering the logo's visual weight. */
@ -1536,16 +1619,10 @@ 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.27-beta · 2026-06-05 12:41:17 · 382645b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></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>
@ -2546,26 +2623,31 @@ body {
} }
}()); }());
// shared/elevation.js — admin elevation via URL toggle. // shared/elevation.js — admin elevation state machine.
// //
// Sudo-style model: admins behave as normal users by default; elevating // Sudo-style model: admins behave as normal users by default; elevating
// the session turns on admin escape hatches (WORM bypass, .zddc edit // the session turns on admin escape hatches (WORM bypass, recursive
// authority, profile admin scaffolds). State is carried in a // delete, rearranging records, profile admin scaffolds — NOT config-edit,
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware // which is standing). State is carried in a `zddc-elevate=1` cookie that
// → zddc.Principal{Elevated}. // the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
// //
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false` // This module owns the STATE (cookie, armed chrome/banner, ephemeral
// (or the red banner's "Drop admin" button) to drop — so it's reachable // lifecycle, the change event) + exposes setOn/setOff/isElevated. The
// from ANY zddc-server page, not just ones that render a header control. // on-page elevate CONTROL lives in the shared profile menu
// The cookie is the sticky state: it persists across navigation for its // (shared/profile-menu.js) — an "Admin mode" item shown only to
// Max-Age window, so the param need not stay in the URL (we strip it). // can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
// Arming is gated on /.profile/access `can_elevate`, so only real admins // into any URL is also honoured (gated on can_elevate), for deep links /
// can set it; a non-admin's ?admin=true is a silent no-op. // scripting.
// //
// Applying the cookie reloads to the cleaned URL so the server re-renders // Admin mode is EPHEMERAL — scoped to the page you turned it on:
// under the new state (admin scaffolds in some tool HTML are server- // * the cookie is a SESSION cookie (no Max-Age), and
// rendered, so a client-only flip wouldn't reach them). The red viewport // * we clear it on `pagehide`, so navigating away / closing the tab
// border + banner (applyArmedChrome) reflect the cookie on every load. // drops admin (you re-arm deliberately on the next page).
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
// would race the clear). SPAs that server-render elevation-dependent data
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
// event we emit and re-fetch. The red viewport border + banner
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
(function () { (function () {
'use strict'; 'use strict';
@ -2586,16 +2668,43 @@ body {
function setElevated(on) { function setElevated(on) {
if (on) { if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF // SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten // shapes. No Max-Age → a SESSION cookie: it dies with the tab
// tab doesn't leave admin powers active indefinitely (sudo's // and, combined with the pagehide handler below, is cleared the
// 5-minute precedent informs the number — 30 minutes is a // moment you leave the page. Admin powers never silently
// reasonable trade between annoyance and exposure). // outlive the page you armed them on.
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800'; document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else { } else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0'; document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
} }
} }
// emitChange notifies same-page listeners (SPAs that server-render
// elevation-dependent data, e.g. browse's listing verbs / editor
// affordances) so they can re-fetch without a full reload.
function emitChange() {
try {
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
detail: { elevated: isElevated() }
}));
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
}
// setOn / setOff are the single funnel for every arm/drop path (the
// profile menu's Admin mode item, the ?admin= URL param, the banner's
// Drop button). Each flips the cookie, re-paints the armed chrome, and
// emits the change — no reload. The profile menu listens for the change
// event to keep its checkbox + armed indicator in sync.
function setOn() {
setElevated(true);
applyArmedChrome(true);
emitChange();
}
function setOff() {
setElevated(false);
applyArmedChrome(false);
emitChange();
}
async function fetchAccess() { async function fetchAccess() {
try { try {
var resp = await fetch('/.profile/access', { var resp = await fetch('/.profile/access', {
@ -2641,34 +2750,26 @@ body {
return u.pathname + (qs ? '?' + qs : '') + u.hash; return u.pathname + (qs ? '?' + qs : '') + u.hash;
} }
// handleAdminParam applies a ?admin= request. Returns true when a // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
// navigation (reload) is underway so the caller can stop. Enabling is // the module header on why reloads would race the pagehide-clear).
// gated on can_elevate — a non-admin who types ?admin=true just gets // Enabling is gated on can_elevate — a non-admin who types ?admin=true
// the param stripped, never a misleading red border. Disabling is open // just gets the param stripped, never a misleading red border.
// (anyone may drop a cookie they somehow hold). // Disabling is open (anyone may drop a cookie they somehow hold).
async function handleAdminParam() { // `access` (a prefetched /.profile/access, may be null) lets init reuse
// its single fetch instead of issuing a second one.
async function handleAdminParam(access) {
var want = adminParam(); var want = adminParam();
if (want === null) return false; if (want === null) return;
var clean = urlWithoutAdmin(); var clean = urlWithoutAdmin();
if (want === isElevated()) { try { history.replaceState(history.state, '', clean); } catch (_e) {}
// Already in the requested state — just clean the URL, no reload. if (want === isElevated()) return; // already in the requested state
try { history.replaceState(history.state, '', clean); } catch (_e) {}
return false;
}
if (want === true) { if (want === true) {
var access = await fetchAccess(); if (access === undefined) access = await fetchAccess();
if (!access || !access.can_elevate) { if (!access || !access.can_elevate) return; // silent no-op
try { history.replaceState(history.state, '', clean); } catch (_e) {} setOn();
return false;
}
setElevated(true);
} else { } else {
setElevated(false); setOff();
} }
// Navigate to the clean URL (a real load, so the server re-renders
// under the new cookie) and replace history so Back is safe.
window.location.replace(clean);
return true;
} }
// Page-wide affordances when elevation is active. The toggle alone // Page-wide affordances when elevation is active. The toggle alone
@ -2699,10 +2800,7 @@ body {
+ '</button>'; + '</button>';
document.body.insertBefore(banner, document.body.firstChild); document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off'); var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () { if (off) off.addEventListener('click', function () { setOff(); });
setElevated(false);
window.location.reload();
});
} }
} else if (banner) { } else if (banner) {
banner.parentNode.removeChild(banner); banner.parentNode.removeChild(banner);
@ -2710,16 +2808,30 @@ body {
} }
async function init() { async function init() {
// Apply (or tear down) the red border + banner from the cookie on // file:// (offline FS-Access mode) has no server to elevate against.
// every page load — admin mode is toggled by URL, but the armed if (window.location.protocol === 'file:') return;
// chrome must surface everywhere so the user can't accidentally
// write through an elevated context on a page they didn't toggle. // Reflect the cookie's armed chrome on every load (a leftover from a
// not-yet-fired pagehide, or an arrived-with ?admin link).
applyArmedChrome(isElevated()); applyArmedChrome(isElevated());
// Honour ?admin=true|false typed into any zddc-server URL. There's // Honour ?admin=true|false typed into any URL — handleAdminParam
// no on-screen toggle anymore — the URL is the enable path and the // fetches /.profile/access itself to gate arming on can_elevate. The
// red banner's "Drop admin" button is the one-click disable. // on-page elevate control lives in the shared profile menu
// (shared/profile-menu.js), which calls setOn/setOff and listens for
// zddc:elevationchange to keep its checkbox + armed ring in sync.
await handleAdminParam(); await handleAdminParam();
// Admin mode is per-page: clear the cookie when the page goes away so
// it never persists past a navigation.
window.addEventListener('pagehide', function () {
if (isElevated()) setElevated(false);
});
// bfcache can restore a page whose pagehide already cleared the
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
window.addEventListener('pageshow', function (e) {
if (e.persisted) applyArmedChrome(isElevated());
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@ -2728,7 +2840,178 @@ body {
init(); init();
} }
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = {
isElevated: isElevated,
setElevated: setElevated,
setOn: setOn,
setOff: setOff
};
})();
// shared/profile-menu.js — account menu in the header's upper-right.
//
// Replaces the old floating elevation toggle. Admin mode is now one item in
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
// / ephemeral state machine stays in shared/elevation.js.
//
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
//
// Server mode only: it reads /.profile/access for the email + can_elevate.
// On file:// (offline FS-Access mode) there's no server account, so nothing
// renders.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.profileMenu) return;
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
async function fetchAccess() {
try {
var r = await fetch('/.profile/access', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!r.ok) return null;
return await r.json();
} catch (_e) { return null; }
}
var elevation = null;
var panelEl = null, btnEl = null, adminInput = null;
function isElevated() {
return !!(elevation && elevation.isElevated && elevation.isElevated());
}
// Keep the button's armed ring + the menu checkbox in lockstep with the
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
function syncArmed() {
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
if (adminInput) adminInput.checked = isElevated();
}
function closeMenu() {
if (panelEl) panelEl.classList.remove('open');
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
}
// The panel is position:fixed (to escape the app's stacking contexts), so
// anchor it to the button rect — top just below it, right-aligned.
function positionPanel() {
var r = btnEl.getBoundingClientRect();
panelEl.style.top = (r.bottom + 4) + 'px';
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
panelEl.style.left = 'auto';
}
function toggleMenu() {
if (!panelEl) return;
var open = panelEl.classList.toggle('open');
if (open) positionPanel();
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function linkItem(text, href) {
var a = el('a', 'profile-menu__item', text);
a.href = href;
a.setAttribute('role', 'menuitem');
return a;
}
function build(access) {
var wrap = el('div', 'profile-menu');
btnEl = el('button', 'btn btn-secondary profile-btn');
btnEl.type = 'button';
btnEl.id = 'profile-btn';
btnEl.title = 'Account: ' + (access.email || 'signed in');
btnEl.setAttribute('aria-haspopup', 'menu');
btnEl.setAttribute('aria-expanded', 'false');
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
wrap.appendChild(btnEl);
panelEl = el('div', 'profile-menu__panel');
panelEl.setAttribute('role', 'menu');
var id = el('div', 'profile-menu__id');
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
panelEl.appendChild(id);
panelEl.appendChild(el('div', 'profile-menu__sep'));
// Admin mode — only offered to principals who actually have admin
// authority somewhere (can_elevate). Drops automatically on leave.
if (access.can_elevate && elevation) {
var row = el('label', 'profile-menu__item profile-menu__toggle');
adminInput = document.createElement('input');
adminInput.type = 'checkbox';
adminInput.className = 'profile-menu__check';
adminInput.checked = isElevated();
adminInput.addEventListener('change', function () {
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
});
row.appendChild(adminInput);
var txt = el('span', 'profile-menu__toggle-label');
txt.appendChild(el('span', null, 'Admin mode'));
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
row.appendChild(txt);
panelEl.appendChild(row);
panelEl.appendChild(el('div', 'profile-menu__sep'));
}
panelEl.appendChild(linkItem('Profile', '/.profile'));
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
// No "Sign out": authentication is the upstream proxy's concern
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
// doesn't render a logout affordance.
// Portal the panel to <body>, not inside the header: the app's
// layout creates stacking contexts that trap even a fixed+high
// z-index panel below the content. As a direct body child it sits in
// the root stacking context and reliably overlays everything.
// position:fixed + positionPanel() keep it anchored to the button.
document.body.appendChild(panelEl);
return wrap;
}
async function init() {
if (window.location.protocol === 'file:') return;
elevation = window.zddc.elevation || null;
var access = await fetchAccess();
if (!access || !access.email) return; // unauthenticated / non-zddc backend
var host = document.querySelector('.header-right');
if (!host) return;
host.appendChild(build(access));
syncArmed();
document.addEventListener('click', function (e) {
if (panelEl && panelEl.classList.contains('open')
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
window.addEventListener('zddc:elevationchange', syncArmed);
window.zddc.profileMenu = { close: closeMenu };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
// shared/cap.js — client-side capability helpers for permission gating. // shared/cap.js — client-side capability helpers for permission gating.

View file

@ -78,6 +78,20 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
/* Shape */ /* Shape */
--radius: 4px; --radius: 4px;
/* Spacing scale — referenced by the tables tool (tables/css/table.css).
Were undefined (var() with no fallback → collapsed to 0), which left
table cells unpadded and the table flush to the viewport edges. */
--spacing-sm: 0.4rem;
--spacing-md: 0.8rem;
--spacing-lg: 1.5rem;
/* Token aliases the tables tool references under --color-*/--radius-*
names; map them to the canonical tokens (themed values flow through). */
--color-text-muted: var(--text-muted);
--color-border: var(--border);
--color-bg-elevated: var(--bg-secondary);
--radius-sm: var(--radius);
/* Typography. --font-display covers headings (Source Serif 4 — a refined /* Typography. --font-display covers headings (Source Serif 4 — a refined
transitional serif that reads as "engineering / document / serious" transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans — without being academic). --font is body UI text (IBM Plex Sans —
@ -859,53 +873,10 @@ body.help-open .app-header {
filter: brightness(1.1); filter: brightness(1.1);
} }
/* shared/elevation.css — admin-elevation toggle in the tool header. /* shared/elevation.css — page-wide armed chrome for admin mode.
Renders only for users with admin scope (handled by elevation.js; The elevate CONTROL is the "Admin mode" item in the shared profile menu
the placeholder is `.hidden` by default). When visible, sits left (shared/profile-menu.{js,css}); this file only styles the unmistakable
of the theme button — sudo-style affordance for opting into admin "you are elevated" cues: the red viewport frame + the sticky banner. */
powers. */
.elevation-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--text-muted);
user-select: none;
cursor: pointer;
padding: 0.15rem 0.45rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.elevation-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-dark);
}
.elevation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
accent-color: var(--danger);
}
.elevation-toggle__label {
cursor: pointer;
letter-spacing: 0.02em;
}
/* Active state — when elevation is ON, the toggle reads as "armed"
so the user can't miss that admin powers are currently live.
:has(:checked) lets us style the wrapper based on the inner
checkbox without JS. */
.elevation-toggle:has(input:checked) {
background: rgba(220, 53, 69, 0.12);
border-color: var(--danger);
color: var(--danger);
font-weight: 600;
}
/* Page-wide chrome when admin mode is active. The toggle alone is /* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue: easy to miss; these add an inescapable visual cue:
@ -982,6 +953,118 @@ body.is-elevated::after {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* shared/profile-menu.css — header account menu (upper-right).
shared/profile-menu.js mounts a button into `.header-right` and toggles
a dropdown with the signed-in email, Admin mode, Profile, Access tokens,
and Sign out. Server mode only. */
.profile-menu {
position: relative;
display: inline-flex;
}
/* The button: a small circular avatar showing the email initial. */
.profile-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
line-height: 1;
}
.profile-btn__avatar {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
}
/* Armed (admin mode on): a red ring so the elevated state reads from the
button even when the menu is closed — pairs with the page banner/frame. */
.profile-btn--armed {
box-shadow: 0 0 0 2px var(--danger, #dc3545);
border-color: var(--danger, #dc3545);
color: var(--danger, #dc3545);
}
.profile-menu__panel {
display: none;
/* Fixed + JS-positioned from the button rect: an absolute panel gets
trapped below the content layer by the app's stacking contexts, so
anchor it to the viewport instead (profile-menu.js sets top/right). */
position: fixed;
min-width: 15rem;
z-index: 9400; /* above the is-elevated frame (9200) + banner (9100) */
background: var(--bg, #fff);
border: 1px solid var(--border, #ddd);
border-radius: var(--radius, 6px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
padding: 0.3rem;
font-size: 0.85rem;
}
.profile-menu__panel.open { display: block; }
.profile-menu__id {
padding: 0.35rem 0.55rem 0.45rem;
}
.profile-menu__email {
font-weight: 600;
color: var(--text, #222);
word-break: break-all;
}
.profile-menu__role {
margin-top: 0.1rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--danger, #dc3545);
}
.profile-menu__sep {
height: 1px;
margin: 0.25rem 0;
background: var(--border, #eee);
}
.profile-menu__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.55rem;
border-radius: var(--radius, 4px);
color: var(--text, #222);
text-decoration: none;
cursor: pointer;
background: none;
border: none;
text-align: left;
font: inherit;
}
.profile-menu__item:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.05));
}
.profile-menu__toggle { cursor: pointer; }
.profile-menu__check {
margin: 0;
cursor: pointer;
accent-color: var(--danger, #dc3545);
flex-shrink: 0;
}
.profile-menu__toggle-label {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.profile-menu__hint {
font-size: 0.72rem;
color: var(--text-muted, #888);
}
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor /* shared/logo.css — paired with shared/logo.js. The wrapping anchor
inherits the logo's box and adds a subtle hover/focus affordance inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */ so it reads as clickable without altering the logo's visual weight. */
@ -2635,7 +2718,7 @@ 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.27-beta · 2026-06-05 12:41:16 · 382645b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3</span></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;
@ -2647,12 +2730,6 @@ 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>
@ -13403,26 +13480,31 @@ X.B(E,Y);return E}return J}())
} }
}()); }());
// shared/elevation.js — admin elevation via URL toggle. // shared/elevation.js — admin elevation state machine.
// //
// Sudo-style model: admins behave as normal users by default; elevating // Sudo-style model: admins behave as normal users by default; elevating
// the session turns on admin escape hatches (WORM bypass, .zddc edit // the session turns on admin escape hatches (WORM bypass, recursive
// authority, profile admin scaffolds). State is carried in a // delete, rearranging records, profile admin scaffolds — NOT config-edit,
// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware // which is standing). State is carried in a `zddc-elevate=1` cookie that
// → zddc.Principal{Elevated}. // the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
// //
// Toggle is by URL query param — `?admin=true` to arm, `?admin=false` // This module owns the STATE (cookie, armed chrome/banner, ephemeral
// (or the red banner's "Drop admin" button) to drop — so it's reachable // lifecycle, the change event) + exposes setOn/setOff/isElevated. The
// from ANY zddc-server page, not just ones that render a header control. // on-page elevate CONTROL lives in the shared profile menu
// The cookie is the sticky state: it persists across navigation for its // (shared/profile-menu.js) — an "Admin mode" item shown only to
// Max-Age window, so the param need not stay in the URL (we strip it). // can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
// Arming is gated on /.profile/access `can_elevate`, so only real admins // into any URL is also honoured (gated on can_elevate), for deep links /
// can set it; a non-admin's ?admin=true is a silent no-op. // scripting.
// //
// Applying the cookie reloads to the cleaned URL so the server re-renders // Admin mode is EPHEMERAL — scoped to the page you turned it on:
// under the new state (admin scaffolds in some tool HTML are server- // * the cookie is a SESSION cookie (no Max-Age), and
// rendered, so a client-only flip wouldn't reach them). The red viewport // * we clear it on `pagehide`, so navigating away / closing the tab
// border + banner (applyArmedChrome) reflect the cookie on every load. // drops admin (you re-arm deliberately on the next page).
// Because of that we apply state IN PLACE (no reload — a reload's pagehide
// would race the clear). SPAs that server-render elevation-dependent data
// (e.g. browse's listing verbs) listen for the `zddc:elevationchange`
// event we emit and re-fetch. The red viewport border + banner
// (applyArmedChrome) reflect the cookie, kept in sync on every change.
(function () { (function () {
'use strict'; 'use strict';
@ -13443,16 +13525,43 @@ X.B(E,Y);return E}return J}())
function setElevated(on) { function setElevated(on) {
if (on) { if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF // SameSite=Lax blocks cross-site form-post / image-tag CSRF
// shapes. Max-Age caps the elevation window so a forgotten // shapes. No Max-Age → a SESSION cookie: it dies with the tab
// tab doesn't leave admin powers active indefinitely (sudo's // and, combined with the pagehide handler below, is cleared the
// 5-minute precedent informs the number — 30 minutes is a // moment you leave the page. Admin powers never silently
// reasonable trade between annoyance and exposure). // outlive the page you armed them on.
document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800'; document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else { } else {
document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0'; document.cookie = COOKIE_NAME + '=; Path=/; SameSite=Lax; Max-Age=0';
} }
} }
// emitChange notifies same-page listeners (SPAs that server-render
// elevation-dependent data, e.g. browse's listing verbs / editor
// affordances) so they can re-fetch without a full reload.
function emitChange() {
try {
window.dispatchEvent(new CustomEvent('zddc:elevationchange', {
detail: { elevated: isElevated() }
}));
} catch (_e) { /* CustomEvent unsupported — non-fatal */ }
}
// setOn / setOff are the single funnel for every arm/drop path (the
// profile menu's Admin mode item, the ?admin= URL param, the banner's
// Drop button). Each flips the cookie, re-paints the armed chrome, and
// emits the change — no reload. The profile menu listens for the change
// event to keep its checkbox + armed indicator in sync.
function setOn() {
setElevated(true);
applyArmedChrome(true);
emitChange();
}
function setOff() {
setElevated(false);
applyArmedChrome(false);
emitChange();
}
async function fetchAccess() { async function fetchAccess() {
try { try {
var resp = await fetch('/.profile/access', { var resp = await fetch('/.profile/access', {
@ -13498,34 +13607,26 @@ X.B(E,Y);return E}return J}())
return u.pathname + (qs ? '?' + qs : '') + u.hash; return u.pathname + (qs ? '?' + qs : '') + u.hash;
} }
// handleAdminParam applies a ?admin= request. Returns true when a // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
// navigation (reload) is underway so the caller can stop. Enabling is // the module header on why reloads would race the pagehide-clear).
// gated on can_elevate — a non-admin who types ?admin=true just gets // Enabling is gated on can_elevate — a non-admin who types ?admin=true
// the param stripped, never a misleading red border. Disabling is open // just gets the param stripped, never a misleading red border.
// (anyone may drop a cookie they somehow hold). // Disabling is open (anyone may drop a cookie they somehow hold).
async function handleAdminParam() { // `access` (a prefetched /.profile/access, may be null) lets init reuse
// its single fetch instead of issuing a second one.
async function handleAdminParam(access) {
var want = adminParam(); var want = adminParam();
if (want === null) return false; if (want === null) return;
var clean = urlWithoutAdmin(); var clean = urlWithoutAdmin();
if (want === isElevated()) { try { history.replaceState(history.state, '', clean); } catch (_e) {}
// Already in the requested state — just clean the URL, no reload. if (want === isElevated()) return; // already in the requested state
try { history.replaceState(history.state, '', clean); } catch (_e) {}
return false;
}
if (want === true) { if (want === true) {
var access = await fetchAccess(); if (access === undefined) access = await fetchAccess();
if (!access || !access.can_elevate) { if (!access || !access.can_elevate) return; // silent no-op
try { history.replaceState(history.state, '', clean); } catch (_e) {} setOn();
return false;
}
setElevated(true);
} else { } else {
setElevated(false); setOff();
} }
// Navigate to the clean URL (a real load, so the server re-renders
// under the new cookie) and replace history so Back is safe.
window.location.replace(clean);
return true;
} }
// Page-wide affordances when elevation is active. The toggle alone // Page-wide affordances when elevation is active. The toggle alone
@ -13556,10 +13657,7 @@ X.B(E,Y);return E}return J}())
+ '</button>'; + '</button>';
document.body.insertBefore(banner, document.body.firstChild); document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off'); var off = banner.querySelector('#elevation-banner-off');
if (off) off.addEventListener('click', function () { if (off) off.addEventListener('click', function () { setOff(); });
setElevated(false);
window.location.reload();
});
} }
} else if (banner) { } else if (banner) {
banner.parentNode.removeChild(banner); banner.parentNode.removeChild(banner);
@ -13567,16 +13665,30 @@ X.B(E,Y);return E}return J}())
} }
async function init() { async function init() {
// Apply (or tear down) the red border + banner from the cookie on // file:// (offline FS-Access mode) has no server to elevate against.
// every page load — admin mode is toggled by URL, but the armed if (window.location.protocol === 'file:') return;
// chrome must surface everywhere so the user can't accidentally
// write through an elevated context on a page they didn't toggle. // Reflect the cookie's armed chrome on every load (a leftover from a
// not-yet-fired pagehide, or an arrived-with ?admin link).
applyArmedChrome(isElevated()); applyArmedChrome(isElevated());
// Honour ?admin=true|false typed into any zddc-server URL. There's // Honour ?admin=true|false typed into any URL — handleAdminParam
// no on-screen toggle anymore — the URL is the enable path and the // fetches /.profile/access itself to gate arming on can_elevate. The
// red banner's "Drop admin" button is the one-click disable. // on-page elevate control lives in the shared profile menu
// (shared/profile-menu.js), which calls setOn/setOff and listens for
// zddc:elevationchange to keep its checkbox + armed ring in sync.
await handleAdminParam(); await handleAdminParam();
// Admin mode is per-page: clear the cookie when the page goes away so
// it never persists past a navigation.
window.addEventListener('pagehide', function () {
if (isElevated()) setElevated(false);
});
// bfcache can restore a page whose pagehide already cleared the
// cookie — re-sync the armed chrome so chrome ≠ cookie can't happen.
window.addEventListener('pageshow', function (e) {
if (e.persisted) applyArmedChrome(isElevated());
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@ -13585,7 +13697,178 @@ X.B(E,Y);return E}return J}())
init(); init();
} }
window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; window.zddc.elevation = {
isElevated: isElevated,
setElevated: setElevated,
setOn: setOn,
setOff: setOff
};
})();
// shared/profile-menu.js — account menu in the header's upper-right.
//
// Replaces the old floating elevation toggle. Admin mode is now one item in
// this dropdown, alongside the signed-in email, Profile, and Access tokens.
// Mounts into the tool header's `.header-right` cluster (every tool ships one)
// and drives elevation via window.zddc.elevation, so the cookie / armed-chrome
// / ephemeral state machine stays in shared/elevation.js.
//
// No logout: authentication is the upstream proxy's concern (oauth2-proxy /
// Authelia) — ZDDC owns no session, so it doesn't touch sign-out.
//
// Server mode only: it reads /.profile/access for the email + can_elevate.
// On file:// (offline FS-Access mode) there's no server account, so nothing
// renders.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.profileMenu) return;
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
async function fetchAccess() {
try {
var r = await fetch('/.profile/access', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!r.ok) return null;
return await r.json();
} catch (_e) { return null; }
}
var elevation = null;
var panelEl = null, btnEl = null, adminInput = null;
function isElevated() {
return !!(elevation && elevation.isElevated && elevation.isElevated());
}
// Keep the button's armed ring + the menu checkbox in lockstep with the
// elevation cookie (flipped here, by ?admin=, or by the banner's Drop).
function syncArmed() {
if (btnEl) btnEl.classList.toggle('profile-btn--armed', isElevated());
if (adminInput) adminInput.checked = isElevated();
}
function closeMenu() {
if (panelEl) panelEl.classList.remove('open');
if (btnEl) btnEl.setAttribute('aria-expanded', 'false');
}
// The panel is position:fixed (to escape the app's stacking contexts), so
// anchor it to the button rect — top just below it, right-aligned.
function positionPanel() {
var r = btnEl.getBoundingClientRect();
panelEl.style.top = (r.bottom + 4) + 'px';
panelEl.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
panelEl.style.left = 'auto';
}
function toggleMenu() {
if (!panelEl) return;
var open = panelEl.classList.toggle('open');
if (open) positionPanel();
btnEl.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function linkItem(text, href) {
var a = el('a', 'profile-menu__item', text);
a.href = href;
a.setAttribute('role', 'menuitem');
return a;
}
function build(access) {
var wrap = el('div', 'profile-menu');
btnEl = el('button', 'btn btn-secondary profile-btn');
btnEl.type = 'button';
btnEl.id = 'profile-btn';
btnEl.title = 'Account: ' + (access.email || 'signed in');
btnEl.setAttribute('aria-haspopup', 'menu');
btnEl.setAttribute('aria-expanded', 'false');
var initial = ((access.email || '?').trim().charAt(0) || '?').toUpperCase();
btnEl.appendChild(el('span', 'profile-btn__avatar', initial));
btnEl.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu(); });
wrap.appendChild(btnEl);
panelEl = el('div', 'profile-menu__panel');
panelEl.setAttribute('role', 'menu');
var id = el('div', 'profile-menu__id');
id.appendChild(el('div', 'profile-menu__email', access.email || '(anonymous)'));
if (access.is_super_admin) id.appendChild(el('div', 'profile-menu__role', 'super admin'));
else if (access.has_any_admin_scope) id.appendChild(el('div', 'profile-menu__role', 'admin'));
panelEl.appendChild(id);
panelEl.appendChild(el('div', 'profile-menu__sep'));
// Admin mode — only offered to principals who actually have admin
// authority somewhere (can_elevate). Drops automatically on leave.
if (access.can_elevate && elevation) {
var row = el('label', 'profile-menu__item profile-menu__toggle');
adminInput = document.createElement('input');
adminInput.type = 'checkbox';
adminInput.className = 'profile-menu__check';
adminInput.checked = isElevated();
adminInput.addEventListener('change', function () {
if (adminInput.checked) elevation.setOn(); else elevation.setOff();
});
row.appendChild(adminInput);
var txt = el('span', 'profile-menu__toggle-label');
txt.appendChild(el('span', null, 'Admin mode'));
txt.appendChild(el('span', 'profile-menu__hint', 'bypass WORM/ACL — drops when you leave the page'));
row.appendChild(txt);
panelEl.appendChild(row);
panelEl.appendChild(el('div', 'profile-menu__sep'));
}
panelEl.appendChild(linkItem('Profile', '/.profile'));
panelEl.appendChild(linkItem('Access tokens', '/.tokens'));
// No "Sign out": authentication is the upstream proxy's concern
// (oauth2-proxy / Authelia). ZDDC doesn't own a session, so it
// doesn't render a logout affordance.
// Portal the panel to <body>, not inside the header: the app's
// layout creates stacking contexts that trap even a fixed+high
// z-index panel below the content. As a direct body child it sits in
// the root stacking context and reliably overlays everything.
// position:fixed + positionPanel() keep it anchored to the button.
document.body.appendChild(panelEl);
return wrap;
}
async function init() {
if (window.location.protocol === 'file:') return;
elevation = window.zddc.elevation || null;
var access = await fetchAccess();
if (!access || !access.email) return; // unauthenticated / non-zddc backend
var host = document.querySelector('.header-right');
if (!host) return;
host.appendChild(build(access));
syncArmed();
document.addEventListener('click', function (e) {
if (panelEl && panelEl.classList.contains('open')
&& !panelEl.contains(e.target) && !btnEl.contains(e.target)) closeMenu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
window.addEventListener('zddc:elevationchange', syncArmed);
window.zddc.profileMenu = { close: closeMenu };
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
// shared/cap.js — client-side capability helpers for permission gating. // shared/cap.js — client-side capability helpers for permission gating.

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b archive=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b transmittal=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b classifier=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b landing=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b form=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b tables=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
browse=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b browse=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3

View file

@ -1648,7 +1648,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.27-dev · 2026-06-07 13:11:11 · 1b9fec6-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">