@@ -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
-// the session turns on admin escape hatches (WORM bypass, .zddc edit
-// authority, profile admin scaffolds). State is carried in a
-// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
-// → zddc.Principal{Elevated}.
+// the session turns on admin escape hatches (WORM bypass, recursive
+// delete, rearranging records, profile admin scaffolds — NOT config-edit,
+// which is standing). State is carried in a `zddc-elevate=1` cookie that
+// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
-// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
-// (or the red banner's "Drop admin" button) to drop — so it's reachable
-// from ANY zddc-server page, not just ones that render a header control.
-// The cookie is the sticky state: it persists across navigation for its
-// Max-Age window, so the param need not stay in the URL (we strip it).
-// Arming is gated on /.profile/access `can_elevate`, so only real admins
-// can set it; a non-admin's ?admin=true is a silent no-op.
+// This module owns the STATE (cookie, armed chrome/banner, ephemeral
+// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
+// on-page elevate CONTROL lives in the shared profile menu
+// (shared/profile-menu.js) — an "Admin mode" item shown only to
+// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
+// into any URL is also honoured (gated on can_elevate), for deep links /
+// scripting.
//
-// Applying the cookie reloads to the cleaned URL so the server re-renders
-// under the new state (admin scaffolds in some tool HTML are server-
-// rendered, so a client-only flip wouldn't reach them). The red viewport
-// border + banner (applyArmedChrome) reflect the cookie on every load.
+// Admin mode is EPHEMERAL — scoped to the page you turned it on:
+// * the cookie is a SESSION cookie (no Max-Age), and
+// * we clear it on `pagehide`, so navigating away / closing the tab
+// 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 () {
'use strict';
@@ -10895,16 +10977,43 @@ window.app.modules.filtering = {
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
- // shapes. Max-Age caps the elevation window so a forgotten
- // tab doesn't leave admin powers active indefinitely (sudo's
- // 5-minute precedent informs the number — 30 minutes is a
- // reasonable trade between annoyance and exposure).
- document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
+ // shapes. No Max-Age → a SESSION cookie: it dies with the tab
+ // and, combined with the pagehide handler below, is cleared the
+ // moment you leave the page. Admin powers never silently
+ // outlive the page you armed them on.
+ document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else {
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() {
try {
var resp = await fetch('/.profile/access', {
@@ -10950,34 +11059,26 @@ window.app.modules.filtering = {
return u.pathname + (qs ? '?' + qs : '') + u.hash;
}
- // handleAdminParam applies a ?admin= request. Returns true when a
- // navigation (reload) is underway so the caller can stop. Enabling is
- // gated on can_elevate — a non-admin who types ?admin=true just gets
- // the param stripped, never a misleading red border. Disabling is open
- // (anyone may drop a cookie they somehow hold).
- async function handleAdminParam() {
+ // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
+ // the module header on why reloads would race the pagehide-clear).
+ // Enabling is gated on can_elevate — a non-admin who types ?admin=true
+ // just gets the param stripped, never a misleading red border.
+ // Disabling is open (anyone may drop a cookie they somehow hold).
+ // `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();
- if (want === null) return false;
+ if (want === null) return;
var clean = urlWithoutAdmin();
- if (want === isElevated()) {
- // Already in the requested state — just clean the URL, no reload.
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
+ try { history.replaceState(history.state, '', clean); } catch (_e) {}
+ if (want === isElevated()) return; // already in the requested state
if (want === true) {
- var access = await fetchAccess();
- if (!access || !access.can_elevate) {
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
- setElevated(true);
+ if (access === undefined) access = await fetchAccess();
+ if (!access || !access.can_elevate) return; // silent no-op
+ setOn();
} 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
@@ -11008,10 +11109,7 @@ window.app.modules.filtering = {
+ '';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
- if (off) off.addEventListener('click', function () {
- setElevated(false);
- window.location.reload();
- });
+ if (off) off.addEventListener('click', function () { setOff(); });
}
} else if (banner) {
banner.parentNode.removeChild(banner);
@@ -11019,16 +11117,30 @@ window.app.modules.filtering = {
}
async function init() {
- // Apply (or tear down) the red border + banner from the cookie on
- // every page load — admin mode is toggled by URL, but the armed
- // chrome must surface everywhere so the user can't accidentally
- // write through an elevated context on a page they didn't toggle.
+ // file:// (offline FS-Access mode) has no server to elevate against.
+ if (window.location.protocol === 'file:') return;
+
+ // 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());
- // Honour ?admin=true|false typed into any zddc-server URL. There's
- // no on-screen toggle anymore — the URL is the enable path and the
- // red banner's "Drop admin" button is the one-click disable.
+ // Honour ?admin=true|false typed into any URL — handleAdminParam
+ // fetches /.profile/access itself to gate arming on can_elevate. The
+ // 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();
+
+ // 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') {
@@ -11037,7 +11149,178 @@ window.app.modules.filtering = {
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 , 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.
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html
index c3037d2..dd6b08a 100644
--- a/zddc/internal/apps/embedded/browse.html
+++ b/zddc/internal/apps/embedded/browse.html
@@ -74,6 +74,20 @@
/* Shape */
--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
transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans —
@@ -1073,53 +1087,10 @@ body.help-open .app-header {
color: var(--text);
}
-/* shared/elevation.css — admin-elevation toggle in the tool header.
- Renders only for users with admin scope (handled by elevation.js;
- the placeholder is `.hidden` by default). When visible, sits left
- of the theme button — sudo-style affordance for opting into admin
- powers. */
-
-.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;
-}
+/* shared/elevation.css — page-wide armed chrome for admin mode.
+ The elevate CONTROL is the "Admin mode" item in the shared profile menu
+ (shared/profile-menu.{js,css}); this file only styles the unmistakable
+ "you are elevated" cues: the red viewport frame + the sticky banner. */
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
@@ -1196,6 +1167,118 @@ body.is-elevated::after {
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);
+}
+
/* browse-specific layout on top of shared/base.css */
html, body {
@@ -1609,7 +1692,7 @@ body {
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
- margin-top: 0.4rem;
+ margin-bottom: 0.4rem;
}
.tree-pane__controls .tp-control {
display: inline-flex;
@@ -2319,6 +2402,16 @@ body {
border-color: var(--primary);
color: var(--primary);
}
+/* The ".zddc schema" badge is clickable — it opens the full JSON Schema. */
+.yaml-shell__schema--link {
+ cursor: pointer;
+}
+.yaml-shell__schema--link:hover,
+.yaml-shell__schema--link:focus-visible {
+ background: var(--primary);
+ color: var(--bg);
+ outline: none;
+}
/* CodeMirror has to fill the grid cell. The vendored CSS sets
`height: 300px` by default — we override to 100% so it grows with
@@ -2546,18 +2639,12 @@ body {
@@ -6211,26 +6297,31 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
window.zddc.menu = { open: open, close: close };
})();
-// 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
-// the session turns on admin escape hatches (WORM bypass, .zddc edit
-// authority, profile admin scaffolds). State is carried in a
-// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
-// → zddc.Principal{Elevated}.
+// the session turns on admin escape hatches (WORM bypass, recursive
+// delete, rearranging records, profile admin scaffolds — NOT config-edit,
+// which is standing). State is carried in a `zddc-elevate=1` cookie that
+// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
-// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
-// (or the red banner's "Drop admin" button) to drop — so it's reachable
-// from ANY zddc-server page, not just ones that render a header control.
-// The cookie is the sticky state: it persists across navigation for its
-// Max-Age window, so the param need not stay in the URL (we strip it).
-// Arming is gated on /.profile/access `can_elevate`, so only real admins
-// can set it; a non-admin's ?admin=true is a silent no-op.
+// This module owns the STATE (cookie, armed chrome/banner, ephemeral
+// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
+// on-page elevate CONTROL lives in the shared profile menu
+// (shared/profile-menu.js) — an "Admin mode" item shown only to
+// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
+// into any URL is also honoured (gated on can_elevate), for deep links /
+// scripting.
//
-// Applying the cookie reloads to the cleaned URL so the server re-renders
-// under the new state (admin scaffolds in some tool HTML are server-
-// rendered, so a client-only flip wouldn't reach them). The red viewport
-// border + banner (applyArmedChrome) reflect the cookie on every load.
+// Admin mode is EPHEMERAL — scoped to the page you turned it on:
+// * the cookie is a SESSION cookie (no Max-Age), and
+// * we clear it on `pagehide`, so navigating away / closing the tab
+// 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 () {
'use strict';
@@ -6251,16 +6342,43 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
- // shapes. Max-Age caps the elevation window so a forgotten
- // tab doesn't leave admin powers active indefinitely (sudo's
- // 5-minute precedent informs the number — 30 minutes is a
- // reasonable trade between annoyance and exposure).
- document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
+ // shapes. No Max-Age → a SESSION cookie: it dies with the tab
+ // and, combined with the pagehide handler below, is cleared the
+ // moment you leave the page. Admin powers never silently
+ // outlive the page you armed them on.
+ document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else {
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() {
try {
var resp = await fetch('/.profile/access', {
@@ -6306,34 +6424,26 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return u.pathname + (qs ? '?' + qs : '') + u.hash;
}
- // handleAdminParam applies a ?admin= request. Returns true when a
- // navigation (reload) is underway so the caller can stop. Enabling is
- // gated on can_elevate — a non-admin who types ?admin=true just gets
- // the param stripped, never a misleading red border. Disabling is open
- // (anyone may drop a cookie they somehow hold).
- async function handleAdminParam() {
+ // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
+ // the module header on why reloads would race the pagehide-clear).
+ // Enabling is gated on can_elevate — a non-admin who types ?admin=true
+ // just gets the param stripped, never a misleading red border.
+ // Disabling is open (anyone may drop a cookie they somehow hold).
+ // `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();
- if (want === null) return false;
+ if (want === null) return;
var clean = urlWithoutAdmin();
- if (want === isElevated()) {
- // Already in the requested state — just clean the URL, no reload.
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
+ try { history.replaceState(history.state, '', clean); } catch (_e) {}
+ if (want === isElevated()) return; // already in the requested state
if (want === true) {
- var access = await fetchAccess();
- if (!access || !access.can_elevate) {
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
- setElevated(true);
+ if (access === undefined) access = await fetchAccess();
+ if (!access || !access.can_elevate) return; // silent no-op
+ setOn();
} 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
@@ -6364,10 +6474,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
+ '';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
- if (off) off.addEventListener('click', function () {
- setElevated(false);
- window.location.reload();
- });
+ if (off) off.addEventListener('click', function () { setOff(); });
}
} else if (banner) {
banner.parentNode.removeChild(banner);
@@ -6375,16 +6482,30 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
async function init() {
- // Apply (or tear down) the red border + banner from the cookie on
- // every page load — admin mode is toggled by URL, but the armed
- // chrome must surface everywhere so the user can't accidentally
- // write through an elevated context on a page they didn't toggle.
+ // file:// (offline FS-Access mode) has no server to elevate against.
+ if (window.location.protocol === 'file:') return;
+
+ // 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());
- // Honour ?admin=true|false typed into any zddc-server URL. There's
- // no on-screen toggle anymore — the URL is the enable path and the
- // red banner's "Drop admin" button is the one-click disable.
+ // Honour ?admin=true|false typed into any URL — handleAdminParam
+ // fetches /.profile/access itself to gate arming on can_elevate. The
+ // 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();
+
+ // 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') {
@@ -6393,7 +6514,178 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
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 , 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.
@@ -7366,6 +7658,19 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return false;
}
+ // isEditableZipMember reports whether node is a member of the .zddc.zip
+ // config bundle — the one case where the server accepts a write into a zip
+ // (ServeZipWrite). The server gates BOTH browsing and writing the bundle on
+ // standing config-edit authority (a subtree admin / `a`-verb holder, no
+ // elevation), so if this member is even visible the session can edit it —
+ // no elevation check needed here. Every other zip member (content archives,
+ // WORM records) stays read-only. The server is the real gate; this drives
+ // editor UX.
+ function isEditableZipMember(node) {
+ if (!node || !node.url || window.app.state.source !== 'server') return false;
+ return /\.zddc\.zip\//i.test(node.url);
+ }
+
// Thrown by saveFile when the server rejects a write with 412
// Precondition Failed — the file changed under us since we loaded it.
// Callers branch on `.status === 412` to open the conflict UI instead
@@ -7460,6 +7765,17 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return name;
}
+ // isTableLeaf reports whether a directory node should behave as a
+ // click-to-table LEAF rather than an expandable folder — i.e. the
+ // cascade resolved its default tool to "tables" (mdl/rsk/ssr and any
+ // operator-configured table dir). The tree renders it without a
+ // chevron and the preview pane opens the tables tool for it. Server
+ // mode only: defaultTool is a server-computed listing hint, absent
+ // offline (file:// folders stay ordinary expandable dirs).
+ function isTableLeaf(node) {
+ return !!(node && node.isDir && node.defaultTool === 'tables');
+ }
+
window.app.modules.util = {
escapeHtml: escapeHtml,
hashContent: hashContent,
@@ -7469,6 +7785,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode,
+ isEditableZipMember: isEditableZipMember,
+ isTableLeaf: isTableLeaf,
saveFile: saveFile,
saveCopy: saveCopy,
ConflictError: ConflictError
@@ -7728,9 +8046,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); }
- // Formats the Export submenu offers for a file (server-side conversion):
- // a file of one of these extensions can be exported as the other two.
- var EXPORT_FORMATS = ['md', 'docx', 'html'];
+ // The Export submenu's convertible-format set comes from the download
+ // module's canonical matrix (download.exportTargets), which mirrors the
+ // server's conversion matrix — the single source of truth shared with the
+ // markdown editor's DOCX/HTML/PDF buttons. exportTargets(ext) returns the
+ // target formats for a source extension (e.g. md → docx, html, pdf), or []
+ // when the extension isn't a convertible source.
+ function exportTargets(ext) {
+ var d = window.app.modules.download;
+ return (d && d.exportTargets) ? d.exportTargets(ext) : [];
+ }
function cap() { return window.zddc && window.zddc.cap; }
function canVerb(node, verb) {
@@ -7862,9 +8187,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
},
{
- // Export submenu: a folder offers ".zip" (both modes); a md/docx/html
- // file offers the OTHER two formats (server-side conversion, so
- // server mode only). A zip is already an archive — no Export.
+ // Export submenu: a folder offers ".zip" (both modes); a convertible
+ // file (md/docx/html) offers its server-side conversion targets —
+ // md → docx/html/pdf, docx → md/html, html → md/docx (server mode
+ // only). A zip is already an archive — no Export.
id: 'export', group: 'io', surfaces: ['row'],
label: 'Export',
appliesTo: function (ctx) {
@@ -7872,7 +8198,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (!n || n.virtual) return false;
if (n.isDir) return true;
if (n.isZip) return false;
- return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1;
+ return isServer() && exportTargets(n.ext).length > 0;
},
items: function (ctx) {
var n = ctx.node;
@@ -7881,8 +8207,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (n.isDir) {
return [{ label: '.zip', action: function () { d.downloadFolder(n); } }];
}
- var cur = (n.ext || '').toLowerCase();
- return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) {
+ // exportTargets already excludes the source format.
+ return exportTargets(n.ext).map(function (fmt) {
return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } };
});
}
@@ -8194,6 +8520,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// context-menu affordance (server mode only — offline has no
// authenticated identity to attribute saves to).
history: !!e.history,
+ // Server-computed: cascade-resolved default tool for a DIRECTORY
+ // entry (e.g. "tables", "classifier"). Browse renders a dir whose
+ // defaultTool=="tables" (mdl/rsk/ssr) as a click-to-table leaf —
+ // the table opens in the preview pane instead of the dir expanding.
+ defaultTool: (typeof e.default_tool === 'string') ? e.default_tool : '',
// FS-API specific (null in server mode):
handle: null
};
@@ -8414,7 +8745,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// undefined — Caddy / FS-API listings (no verbs field).
// Per-entry gates skip the cascade check
// and fall back to canMutate / writable.
- verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined
+ verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined,
+ // Cascade default tool for a directory entry. When "tables"
+ // (mdl/rsk/ssr), the node is a TABLE LEAF: rendered without a
+ // chevron and, on click, opens the tables tool in the preview
+ // pane instead of expanding/navigating. See isTableLeaf().
+ defaultTool: raw.defaultTool || ''
};
state.nodes.set(id, node);
return node;
@@ -8631,6 +8967,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
};
function symbolForNode(node) {
+ // Table-leaf dirs (mdl/rsk/ssr) read as a table, not a folder.
+ if (window.app.modules.util.isTableLeaf(node)) return 'icon-file-spreadsheet';
if (node.isDir) return 'icon-folder';
if (node.isZip) return 'icon-folder-archive';
// `.zddc` (no extension) is the cascade config — same family
@@ -8707,7 +9045,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// via the events.js click handler (it sees the modifier key).
function rowHtml(node) {
var indent = 0.4 + node.depth * 1.0;
- var expandable = node.isDir || node.isZip;
+ // Table-leaf dirs render like a file: no chevron, click opens the
+ // table in the preview pane (handled by events.js / preview.js).
+ var tableLeaf = window.app.modules.util.isTableLeaf(node);
+ var expandable = (node.isDir || node.isZip) && !tableLeaf;
var iconChar = iconForNode(node);
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
@@ -8741,6 +9082,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ + (tableLeaf ? ' data-tableleaf="true"' : '')
+ (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
@@ -9164,7 +9506,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
function editorModules() {
var m = window.app.modules;
- return [m.markdown, m.yamledit].filter(Boolean);
+ return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
}
function disposeEditors() {
@@ -9275,6 +9617,19 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return;
}
+ // .zddc form view: a schema-driven form (option fields editable,
+ // structure read-only) is the PRIMARY editor for .zddc files. It hands
+ // off to the raw YAML editor on demand. Other YAML files skip it.
+ var zddcForm = window.app.modules.zddcform;
+ if (zddcForm && zddcForm.handles(node)) {
+ try {
+ await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
+ } catch (e) {
+ renderError(container, '.zddc form render failed: ' + (e.message || e));
+ }
+ return;
+ }
+
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
// CodeMirror 5 editor with js-yaml linting; .zddc files also
// get a schema-aware lint pass.
@@ -9520,12 +9875,47 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// ── Public entry ────────────────────────────────────────────────────────
async function showFilePreview(node, opts) {
- if (node.isDir) return;
opts = opts || {};
+ // Table-leaf dirs (mdl/rsk/ssr, default_tool=tables) open the tables
+ // tool inline in the preview pane instead of expanding/navigating.
+ if (window.app.modules.util.isTableLeaf(node)) return renderTableLeaf(node);
+ if (node.isDir) return;
if (opts.popup) return renderInPopup(node);
return renderInline(node, opts);
}
+ // renderTableLeaf embeds the tables tool for a default_tool=tables
+ // directory as an iframe scoped to that dir — the same in-pane tool
+ // embed pattern grid.js uses for classifier. Server mode only (the
+ // default_tool listing hint that flags a table-leaf is absent offline,
+ // so this never fires on file:// — the dir stays an ordinary folder).
+ function renderTableLeaf(node) {
+ disposeEditors();
+ var container = document.getElementById('previewBody');
+ var titleEl = document.getElementById('previewTitle');
+ var metaEl = document.getElementById('previewMeta');
+ var popoutBtn = document.getElementById('previewPopout');
+ if (!container) return;
+ if (titleEl) titleEl.textContent = node.displayName || node.name;
+ if (metaEl) metaEl.textContent = 'table';
+ if (popoutBtn) popoutBtn.classList.add('hidden');
+ if (window.app.state.source !== 'server' || !node.url) {
+ renderEmpty(container, 'Table view is available in server mode.');
+ return;
+ }
+ // The tables tool is served at the dir's NO-SLASH URL (the cascade's
+ // default_tool routing). The trailing-slash form would serve the
+ // browse listing instead, and /tables.html 404s for a virtual
+ // dir (mdl/rsk/ssr have no on-disk folder). So strip the slash.
+ var src = node.url.replace(/\/+$/, '');
+ container.innerHTML = '';
+ var frame = document.createElement('iframe');
+ frame.className = 'preview-iframe';
+ frame.src = src;
+ frame.setAttribute('title', 'Table: ' + (node.displayName || node.name));
+ container.appendChild(frame);
+ }
+
window.app.modules.preview = {
showFilePreview: showFilePreview,
// Tear down any live editor + blank the pane (rescope / popstate).
@@ -9616,6 +10006,38 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
// ── Front matter ────────────────────────────────────────────────────────
+ // Cached recognised-front-matter placeholder, fetched once from the server
+ // (/.api/frontmatter — the single source of truth that mirrors the
+ // converter's RecognizedFrontMatter). null = not yet fetched; '' = fetched
+ // empty / unavailable. The promise dedupes concurrent fetches.
+ var fmPlaceholder = null;
+ var fmPlaceholderPromise = null;
+
+ // applyFrontMatterPlaceholder sets the textarea placeholder to the server's
+ // recognised-field hint, in server mode only. Async + best-effort: a failed
+ // fetch leaves the pane blank (no placeholder), never an error.
+ function applyFrontMatterPlaceholder(textarea) {
+ var st = window.app && window.app.state;
+ if (!st || st.source !== 'server') return;
+ if (fmPlaceholder !== null) {
+ textarea.placeholder = fmPlaceholder;
+ return;
+ }
+ if (!fmPlaceholderPromise) {
+ fmPlaceholderPromise = fetch('/.api/frontmatter', {
+ headers: { 'Accept': 'application/json' },
+ credentials: 'same-origin'
+ }).then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
+ .catch(function () { fmPlaceholder = ''; });
+ }
+ fmPlaceholderPromise.then(function () {
+ // Only apply if this textarea is still in the DOM (user may have
+ // switched files before the fetch resolved).
+ if (textarea.isConnected) textarea.placeholder = fmPlaceholder;
+ });
+ }
+
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
@@ -9823,9 +10245,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
var isZipMemberNode = util.isZipMemberNode;
+ var isEditableZipMember = util.isEditableZipMember;
function canSave(node) {
- if (isZipMemberNode(node)) return false;
+ // A .zddc.zip bundle member is saveable iff editable (elevated admin) —
+ // the server's ServeZipWrite is the gate; other zip members read-only.
+ if (isZipMemberNode(node)) return isEditableZipMember(node);
// Server-computed authority gate. The listing's verbs string
// tells us whether a PUT to this entry would be allowed —
// false here means the file API would 403, so we mount in
@@ -9908,12 +10333,29 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
fmTextarea.spellcheck = false;
fmTextarea.autocapitalize = 'off';
fmTextarea.autocomplete = 'off';
- // No placeholder text — files with no YAML front matter render
- // as a genuinely empty pane. Showing a synthetic example would
- // make the file look like it had data when it doesn't.
+ // Placeholder: in server mode, hint the recognised front-matter keys
+ // (doctype, numbering, …) as greyed text so authors can discover them.
+ // It's placeholder-only — inserts nothing, vanishes on the first
+ // keystroke — so arbitrary keys stay free and a file with no front
+ // matter still renders as a genuinely empty pane. The text is fetched
+ // from the server (/.api/frontmatter), the single source of truth, so
+ // it never drifts from what the converter honours. file:// mode shows
+ // no placeholder (conversion is server-only).
fmTextarea.placeholder = '';
+ applyFrontMatterPlaceholder(fmTextarea);
fmBody.appendChild(fmTextarea);
+ // Non-blocking warning shown when front matter disagrees with the
+ // canonical filename on an identity field (tracking_number / revision /
+ // status / title). The filename always wins in the rendered doc; this
+ // just tells the author their front-matter value is being ignored.
+ var fmWarn = document.createElement('div');
+ fmWarn.className = 'md-fm__warn';
+ fmWarn.hidden = true;
+ fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
+ + '#fcd34d;border-radius:4px;padding:4px 8px;margin:0 0 4px;font-size:'
+ + '0.78rem;line-height:1.4;';
fmSection.appendChild(fmHeader);
+ fmSection.appendChild(fmWarn);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
@@ -9977,7 +10419,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (isZipMemberNode(node)) {
- sourceEl.textContent = 'read-only (zip)';
+ sourceEl.textContent = isEditableZipMember(node) ? 'config bundle' : 'read-only (zip)';
} else if (node.handle) {
sourceEl.textContent = 'local';
} else if (node.url) {
@@ -10005,11 +10447,18 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// and routes through ServeConverted. Cleaner than the
// old `?convert=` query form — right-clicking the link
// gives a sensible "Save as .docx" prompt.
- var mdUrlBase = node.url.replace(/\.md$/i, '');
- ['docx', 'html', 'pdf'].forEach(function (fmt) {
+ //
+ // Format set + URL come from the download module's canonical
+ // conversion matrix (download.exportTargets / convertUrl) — the
+ // SAME source of truth the Export context-menu uses, so the
+ // editor's buttons and the menu never offer different formats.
+ var dl = window.app.modules.download;
+ var mdTargets = (dl && dl.exportTargets) ? dl.exportTargets('md') : ['docx', 'html', 'pdf'];
+ mdTargets.forEach(function (fmt) {
var a = document.createElement('a');
a.className = 'btn btn-sm btn-secondary md-shell__download';
- a.href = mdUrlBase + '.' + fmt;
+ a.href = (dl && dl.convertUrl) ? dl.convertUrl(node.url, fmt)
+ : node.url.replace(/\.md$/i, '') + '.' + fmt;
// target=_blank: clicks open in a new tab. The server
// sends Content-Disposition: inline, so the new tab
// either renders (HTML → web page; PDF → browser's
@@ -10231,14 +10680,49 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}, 250);
editor.on('change', onChange);
+ // Identity fields are sourced from the canonical ZDDC filename; setting
+ // a different value in front matter is ignored at render (the filename
+ // wins). Surface a mismatch so the author isn't silently overridden.
+ // Maps the front-matter key to the parseFilename field.
+ var IDENTITY_FIELDS = [
+ { fm: 'title', fn: 'title', label: 'title' },
+ { fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
+ { fm: 'revision', fn: 'revision', label: 'revision' },
+ { fm: 'status', fn: 'status', label: 'status' }
+ ];
+ function checkFilenameMismatch() {
+ var z = window.zddc;
+ var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
+ // Only meaningful for a conventional ZDDC filename (it always has a
+ // tracking number). Non-conventional files have no canonical
+ // identity, so front matter is free — no warning.
+ if (!fn || !fn.trackingNumber) { fmWarn.hidden = true; return; }
+ var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
+ var clashes = [];
+ IDENTITY_FIELDS.forEach(function (f) {
+ if (!(f.fm in data)) return;
+ var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
+ var want = String(fn[f.fn] == null ? '' : fn[f.fn]).trim();
+ if (got !== '' && want !== '' && got !== want) {
+ clashes.push(f.label + ' “' + got + '” ≠ filename “' + want + '”');
+ }
+ });
+ if (!clashes.length) { fmWarn.hidden = true; fmWarn.textContent = ''; return; }
+ fmWarn.textContent = '⚠ Front matter disagrees with the filename (the '
+ + 'filename wins): ' + clashes.join('; ') + '.';
+ fmWarn.hidden = false;
+ }
+
var onFmChange = debounce(async function () {
if (currentInstance !== instance) return;
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
+ checkFilenameMismatch();
}, 250);
fmTextarea.addEventListener('input', onFmChange);
+ checkFilenameMismatch(); // initial state on load
// ── Save ───────────────────────────────────────────────────────────
// Mark a successful write: adopt the new server ETag (so the next
@@ -10433,9 +10917,13 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
var isZipMemberNode = util.isZipMemberNode;
+ var isEditableZipMember = util.isEditableZipMember;
function canSave(node) {
- if (isZipMemberNode(node)) return false;
+ // A .zddc.zip bundle member is saveable iff editable (elevated admin);
+ // the server's ServeZipWrite is the real gate. Other zip members are
+ // read-only.
+ if (isZipMemberNode(node)) return isEditableZipMember(node);
// Virtual .zddc placeholders are designed to be saved — a PUT
// materializes the file from the synthetic body and the next
// listing serves a real entry. Every other virtual node (per-
@@ -10490,7 +10978,18 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
views: 'viewmap',
convert: 'convert',
created_by: 'string',
- inherit: 'bool'
+ inherit: 'bool',
+ // Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the
+ // lint was missing — flagged valid configs as "unknown key".
+ party_source: 'string',
+ history: 'bool',
+ history_globs: 'string[]',
+ records: 'object',
+ auto_own_roles: 'string[]',
+ received_path: 'string',
+ planned_response_date: 'string',
+ planned_review_date: 'string',
+ field_codes: 'object'
};
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
@@ -10655,6 +11154,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, CONVERT_KEYS, path, issues);
return;
+ case 'object':
+ // Free-form map (records, field_codes) — the server accepts any
+ // nested shape, so we only check it's a mapping, not its keys.
+ if (t === 'null') return;
+ if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
+ return;
}
}
@@ -10809,9 +11314,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var schemaTag = document.createElement('span');
schemaTag.className = 'md-shell__source yaml-shell__schema';
if (isZddcFile(node.name)) {
- schemaTag.textContent = '.zddc schema';
+ schemaTag.textContent = '.zddc schema ↗';
schemaTag.title = 'Linted against the .zddc cascade schema '
- + '(unknown keys, bad enums, and wrong types are flagged).';
+ + '(unknown keys, bad enums, and wrong types are flagged). '
+ + 'Click to view the full JSON Schema.';
+ // Clickable → opens the canonical machine grammar the lint mirrors.
+ schemaTag.classList.add('yaml-shell__schema--link');
+ schemaTag.setAttribute('role', 'link');
+ schemaTag.setAttribute('tabindex', '0');
+ var openSchema = function () {
+ window.open('/.api/zddc-schema', '_blank', 'noopener');
+ };
+ schemaTag.addEventListener('click', openSchema);
+ schemaTag.addEventListener('keydown', function (ev) {
+ if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
+ });
} else {
schemaTag.textContent = 'YAML';
}
@@ -10824,7 +11341,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
- if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
+ if (isZipMemberNode(node)) sourceEl.textContent = isEditableZipMember(node) ? 'config bundle' : 'read-only (zip)';
else if (node.handle) sourceEl.textContent = 'local';
else if (node.url) sourceEl.textContent = 'server';
@@ -11016,6 +11533,332 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
};
})();
+// preview-zddc-form.js — schema-driven FORM view for .zddc files.
+//
+// The user shouldn't have to understand YAML cascades to configure a project.
+// This renders the .zddc as a form: the OPTION fields (the blanks an operator
+// fills — title, admins, role members) are editable widgets; the STRUCTURE
+// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
+// for context. The split is driven by the server's .zddc JSON Schema
+// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
+// option values back into the file (preserving all structure keys) and PUTs the
+// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
+// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
+// CodeMirror editor for anything the form doesn't cover (field_codes, display,
+// convert, advanced acl).
+//
+// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
+// the power-user fallback.
+(function (app) {
+ 'use strict';
+
+ var util = app.modules.util || window.app.modules.util;
+ var escapeHtml = util.escapeHtml;
+ var saveFile = util.saveFile;
+ var isEditableZipMember = util.isEditableZipMember;
+
+ var current = null; // { node, dirty, etag, lastModified }
+
+ // Cached .zddc schema (property → {tier, description}).
+ var schemaProps = null;
+ function loadSchema() {
+ if (schemaProps) return Promise.resolve(schemaProps);
+ return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
+ .catch(function () { schemaProps = {}; return schemaProps; });
+ }
+
+ function handles(node) {
+ return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
+ }
+
+ function canSave(node) {
+ if (isEditableZipMember(node)) return true;
+ if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
+ // A .zddc edit is an ActionAdmin write — needs the 'a' verb.
+ return window.zddc.cap.has(node, 'a');
+ }
+ return false;
+ }
+
+ function isDirty() { return !!(current && current.dirty); }
+ function currentNode() { return current ? current.node : null; }
+ function dispose() { current = null; }
+
+ function desc(name) {
+ return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
+ }
+
+ // ── small DOM helpers ───────────────────────────────────────────────────
+ function el(tag, cls, text) {
+ var e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ }
+ // A growable list of single-string rows (used for admins + role members).
+ function listEditor(values, placeholder, onChange, readOnly) {
+ var wrap = el('div', 'zf-list');
+ function addRow(val) {
+ var row = el('div', 'zf-list__row');
+ row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
+ var input = el('input');
+ input.type = 'text';
+ input.value = val || '';
+ input.placeholder = placeholder || '';
+ input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
+ input.disabled = !!readOnly;
+ input.addEventListener('input', onChange);
+ row.appendChild(input);
+ if (!readOnly) {
+ var del = el('button', null, '−');
+ del.type = 'button';
+ del.title = 'Remove';
+ del.addEventListener('click', function () { row.remove(); onChange(); });
+ row.appendChild(del);
+ }
+ wrap.appendChild(row);
+ }
+ (values || []).forEach(addRow);
+ if (!readOnly) {
+ var add = el('button', 'zf-add', '+ add');
+ add.type = 'button';
+ add.style.cssText = 'margin-top:.2rem;';
+ add.addEventListener('click', function () { addRow(''); onChange(); });
+ wrap.appendChild(add);
+ }
+ wrap._values = function () {
+ return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
+ .map(function (i) { return i.value.trim(); })
+ .filter(function (v) { return v; });
+ };
+ return wrap;
+ }
+
+ async function render(node, container, ctx) {
+ dispose();
+ var text, etag = null, lastModified = null;
+ try {
+ if (ctx.getContentWithVersion) {
+ var loaded = await ctx.getContentWithVersion(node);
+ text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
+ etag = loaded.etag;
+ lastModified = loaded.lastModified;
+ } else {
+ text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
+ }
+ } catch (e) {
+ container.innerHTML = '
@@ -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
-// the session turns on admin escape hatches (WORM bypass, .zddc edit
-// authority, profile admin scaffolds). State is carried in a
-// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
-// → zddc.Principal{Elevated}.
+// the session turns on admin escape hatches (WORM bypass, recursive
+// delete, rearranging records, profile admin scaffolds — NOT config-edit,
+// which is standing). State is carried in a `zddc-elevate=1` cookie that
+// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
-// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
-// (or the red banner's "Drop admin" button) to drop — so it's reachable
-// from ANY zddc-server page, not just ones that render a header control.
-// The cookie is the sticky state: it persists across navigation for its
-// Max-Age window, so the param need not stay in the URL (we strip it).
-// Arming is gated on /.profile/access `can_elevate`, so only real admins
-// can set it; a non-admin's ?admin=true is a silent no-op.
+// This module owns the STATE (cookie, armed chrome/banner, ephemeral
+// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
+// on-page elevate CONTROL lives in the shared profile menu
+// (shared/profile-menu.js) — an "Admin mode" item shown only to
+// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
+// into any URL is also honoured (gated on can_elevate), for deep links /
+// scripting.
//
-// Applying the cookie reloads to the cleaned URL so the server re-renders
-// under the new state (admin scaffolds in some tool HTML are server-
-// rendered, so a client-only flip wouldn't reach them). The red viewport
-// border + banner (applyArmedChrome) reflect the cookie on every load.
+// Admin mode is EPHEMERAL — scoped to the page you turned it on:
+// * the cookie is a SESSION cookie (no Max-Age), and
+// * we clear it on `pagehide`, so navigating away / closing the tab
+// 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 () {
'use strict';
@@ -10019,16 +10101,43 @@ X.B(E,Y);return E}return J}())
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
- // shapes. Max-Age caps the elevation window so a forgotten
- // tab doesn't leave admin powers active indefinitely (sudo's
- // 5-minute precedent informs the number — 30 minutes is a
- // reasonable trade between annoyance and exposure).
- document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
+ // shapes. No Max-Age → a SESSION cookie: it dies with the tab
+ // and, combined with the pagehide handler below, is cleared the
+ // moment you leave the page. Admin powers never silently
+ // outlive the page you armed them on.
+ document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else {
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() {
try {
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;
}
- // handleAdminParam applies a ?admin= request. Returns true when a
- // navigation (reload) is underway so the caller can stop. Enabling is
- // gated on can_elevate — a non-admin who types ?admin=true just gets
- // the param stripped, never a misleading red border. Disabling is open
- // (anyone may drop a cookie they somehow hold).
- async function handleAdminParam() {
+ // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
+ // the module header on why reloads would race the pagehide-clear).
+ // Enabling is gated on can_elevate — a non-admin who types ?admin=true
+ // just gets the param stripped, never a misleading red border.
+ // Disabling is open (anyone may drop a cookie they somehow hold).
+ // `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();
- if (want === null) return false;
+ if (want === null) return;
var clean = urlWithoutAdmin();
- if (want === isElevated()) {
- // Already in the requested state — just clean the URL, no reload.
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
+ try { history.replaceState(history.state, '', clean); } catch (_e) {}
+ if (want === isElevated()) return; // already in the requested state
if (want === true) {
- var access = await fetchAccess();
- if (!access || !access.can_elevate) {
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
- setElevated(true);
+ if (access === undefined) access = await fetchAccess();
+ if (!access || !access.can_elevate) return; // silent no-op
+ setOn();
} 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
@@ -10132,10 +10233,7 @@ X.B(E,Y);return E}return J}())
+ '';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
- if (off) off.addEventListener('click', function () {
- setElevated(false);
- window.location.reload();
- });
+ if (off) off.addEventListener('click', function () { setOff(); });
}
} else if (banner) {
banner.parentNode.removeChild(banner);
@@ -10143,16 +10241,30 @@ X.B(E,Y);return E}return J}())
}
async function init() {
- // Apply (or tear down) the red border + banner from the cookie on
- // every page load — admin mode is toggled by URL, but the armed
- // chrome must surface everywhere so the user can't accidentally
- // write through an elevated context on a page they didn't toggle.
+ // file:// (offline FS-Access mode) has no server to elevate against.
+ if (window.location.protocol === 'file:') return;
+
+ // 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());
- // Honour ?admin=true|false typed into any zddc-server URL. There's
- // no on-screen toggle anymore — the URL is the enable path and the
- // red banner's "Drop admin" button is the one-click disable.
+ // Honour ?admin=true|false typed into any URL — handleAdminParam
+ // fetches /.profile/access itself to gate arming on can_elevate. The
+ // 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();
+
+ // 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') {
@@ -10161,7 +10273,178 @@ X.B(E,Y);return E}return J}())
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 , 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.
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html
index 526440d..c1c65f8 100644
--- a/zddc/internal/apps/embedded/index.html
+++ b/zddc/internal/apps/embedded/index.html
@@ -74,6 +74,20 @@
/* Shape */
--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
transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans —
@@ -855,53 +869,10 @@ body.help-open .app-header {
filter: brightness(1.1);
}
-/* shared/elevation.css — admin-elevation toggle in the tool header.
- Renders only for users with admin scope (handled by elevation.js;
- the placeholder is `.hidden` by default). When visible, sits left
- of the theme button — sudo-style affordance for opting into admin
- powers. */
-
-.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;
-}
+/* shared/elevation.css — page-wide armed chrome for admin mode.
+ The elevate CONTROL is the "Admin mode" item in the shared profile menu
+ (shared/profile-menu.{js,css}); this file only styles the unmistakable
+ "you are elevated" cues: the red viewport frame + the sticky banner. */
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
@@ -978,6 +949,118 @@ body.is-elevated::after {
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
inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */
@@ -1536,16 +1619,10 @@ body {
@@ -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
-// the session turns on admin escape hatches (WORM bypass, .zddc edit
-// authority, profile admin scaffolds). State is carried in a
-// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
-// → zddc.Principal{Elevated}.
+// the session turns on admin escape hatches (WORM bypass, recursive
+// delete, rearranging records, profile admin scaffolds — NOT config-edit,
+// which is standing). State is carried in a `zddc-elevate=1` cookie that
+// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
-// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
-// (or the red banner's "Drop admin" button) to drop — so it's reachable
-// from ANY zddc-server page, not just ones that render a header control.
-// The cookie is the sticky state: it persists across navigation for its
-// Max-Age window, so the param need not stay in the URL (we strip it).
-// Arming is gated on /.profile/access `can_elevate`, so only real admins
-// can set it; a non-admin's ?admin=true is a silent no-op.
+// This module owns the STATE (cookie, armed chrome/banner, ephemeral
+// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
+// on-page elevate CONTROL lives in the shared profile menu
+// (shared/profile-menu.js) — an "Admin mode" item shown only to
+// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
+// into any URL is also honoured (gated on can_elevate), for deep links /
+// scripting.
//
-// Applying the cookie reloads to the cleaned URL so the server re-renders
-// under the new state (admin scaffolds in some tool HTML are server-
-// rendered, so a client-only flip wouldn't reach them). The red viewport
-// border + banner (applyArmedChrome) reflect the cookie on every load.
+// Admin mode is EPHEMERAL — scoped to the page you turned it on:
+// * the cookie is a SESSION cookie (no Max-Age), and
+// * we clear it on `pagehide`, so navigating away / closing the tab
+// 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 () {
'use strict';
@@ -2586,16 +2668,43 @@ body {
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
- // shapes. Max-Age caps the elevation window so a forgotten
- // tab doesn't leave admin powers active indefinitely (sudo's
- // 5-minute precedent informs the number — 30 minutes is a
- // reasonable trade between annoyance and exposure).
- document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
+ // shapes. No Max-Age → a SESSION cookie: it dies with the tab
+ // and, combined with the pagehide handler below, is cleared the
+ // moment you leave the page. Admin powers never silently
+ // outlive the page you armed them on.
+ document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else {
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() {
try {
var resp = await fetch('/.profile/access', {
@@ -2641,34 +2750,26 @@ body {
return u.pathname + (qs ? '?' + qs : '') + u.hash;
}
- // handleAdminParam applies a ?admin= request. Returns true when a
- // navigation (reload) is underway so the caller can stop. Enabling is
- // gated on can_elevate — a non-admin who types ?admin=true just gets
- // the param stripped, never a misleading red border. Disabling is open
- // (anyone may drop a cookie they somehow hold).
- async function handleAdminParam() {
+ // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
+ // the module header on why reloads would race the pagehide-clear).
+ // Enabling is gated on can_elevate — a non-admin who types ?admin=true
+ // just gets the param stripped, never a misleading red border.
+ // Disabling is open (anyone may drop a cookie they somehow hold).
+ // `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();
- if (want === null) return false;
+ if (want === null) return;
var clean = urlWithoutAdmin();
- if (want === isElevated()) {
- // Already in the requested state — just clean the URL, no reload.
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
+ try { history.replaceState(history.state, '', clean); } catch (_e) {}
+ if (want === isElevated()) return; // already in the requested state
if (want === true) {
- var access = await fetchAccess();
- if (!access || !access.can_elevate) {
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
- setElevated(true);
+ if (access === undefined) access = await fetchAccess();
+ if (!access || !access.can_elevate) return; // silent no-op
+ setOn();
} 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
@@ -2699,10 +2800,7 @@ body {
+ '';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
- if (off) off.addEventListener('click', function () {
- setElevated(false);
- window.location.reload();
- });
+ if (off) off.addEventListener('click', function () { setOff(); });
}
} else if (banner) {
banner.parentNode.removeChild(banner);
@@ -2710,16 +2808,30 @@ body {
}
async function init() {
- // Apply (or tear down) the red border + banner from the cookie on
- // every page load — admin mode is toggled by URL, but the armed
- // chrome must surface everywhere so the user can't accidentally
- // write through an elevated context on a page they didn't toggle.
+ // file:// (offline FS-Access mode) has no server to elevate against.
+ if (window.location.protocol === 'file:') return;
+
+ // 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());
- // Honour ?admin=true|false typed into any zddc-server URL. There's
- // no on-screen toggle anymore — the URL is the enable path and the
- // red banner's "Drop admin" button is the one-click disable.
+ // Honour ?admin=true|false typed into any URL — handleAdminParam
+ // fetches /.profile/access itself to gate arming on can_elevate. The
+ // 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();
+
+ // 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') {
@@ -2728,7 +2840,178 @@ body {
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 , 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.
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html
index bcde642..ce6236b 100644
--- a/zddc/internal/apps/embedded/transmittal.html
+++ b/zddc/internal/apps/embedded/transmittal.html
@@ -78,6 +78,20 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
/* Shape */
--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
transitional serif that reads as "engineering / document / serious"
without being academic). --font is body UI text (IBM Plex Sans —
@@ -859,53 +873,10 @@ body.help-open .app-header {
filter: brightness(1.1);
}
-/* shared/elevation.css — admin-elevation toggle in the tool header.
- Renders only for users with admin scope (handled by elevation.js;
- the placeholder is `.hidden` by default). When visible, sits left
- of the theme button — sudo-style affordance for opting into admin
- powers. */
-
-.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;
-}
+/* shared/elevation.css — page-wide armed chrome for admin mode.
+ The elevate CONTROL is the "Admin mode" item in the shared profile menu
+ (shared/profile-menu.{js,css}); this file only styles the unmistakable
+ "you are elevated" cues: the red viewport frame + the sticky banner. */
/* Page-wide chrome when admin mode is active. The toggle alone is
easy to miss; these add an inescapable visual cue:
@@ -982,6 +953,118 @@ body.is-elevated::after {
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
inherits the logo's box and adds a subtle hover/focus affordance
so it reads as clickable without altering the logo's visual weight. */
@@ -2635,7 +2718,7 @@ dialog.modal--narrow {
JavaScript not available
-
@@ -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
-// the session turns on admin escape hatches (WORM bypass, .zddc edit
-// authority, profile admin scaffolds). State is carried in a
-// `zddc-elevate=1` cookie that the server reads via handler.ACLMiddleware
-// → zddc.Principal{Elevated}.
+// the session turns on admin escape hatches (WORM bypass, recursive
+// delete, rearranging records, profile admin scaffolds — NOT config-edit,
+// which is standing). State is carried in a `zddc-elevate=1` cookie that
+// the server reads via handler.ACLMiddleware → zddc.Principal{Elevated}.
//
-// Toggle is by URL query param — `?admin=true` to arm, `?admin=false`
-// (or the red banner's "Drop admin" button) to drop — so it's reachable
-// from ANY zddc-server page, not just ones that render a header control.
-// The cookie is the sticky state: it persists across navigation for its
-// Max-Age window, so the param need not stay in the URL (we strip it).
-// Arming is gated on /.profile/access `can_elevate`, so only real admins
-// can set it; a non-admin's ?admin=true is a silent no-op.
+// This module owns the STATE (cookie, armed chrome/banner, ephemeral
+// lifecycle, the change event) + exposes setOn/setOff/isElevated. The
+// on-page elevate CONTROL lives in the shared profile menu
+// (shared/profile-menu.js) — an "Admin mode" item shown only to
+// can_elevate users — which calls setOn/setOff. `?admin=true|false` typed
+// into any URL is also honoured (gated on can_elevate), for deep links /
+// scripting.
//
-// Applying the cookie reloads to the cleaned URL so the server re-renders
-// under the new state (admin scaffolds in some tool HTML are server-
-// rendered, so a client-only flip wouldn't reach them). The red viewport
-// border + banner (applyArmedChrome) reflect the cookie on every load.
+// Admin mode is EPHEMERAL — scoped to the page you turned it on:
+// * the cookie is a SESSION cookie (no Max-Age), and
+// * we clear it on `pagehide`, so navigating away / closing the tab
+// 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 () {
'use strict';
@@ -13443,16 +13525,43 @@ X.B(E,Y);return E}return J}())
function setElevated(on) {
if (on) {
// SameSite=Lax blocks cross-site form-post / image-tag CSRF
- // shapes. Max-Age caps the elevation window so a forgotten
- // tab doesn't leave admin powers active indefinitely (sudo's
- // 5-minute precedent informs the number — 30 minutes is a
- // reasonable trade between annoyance and exposure).
- document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax; Max-Age=1800';
+ // shapes. No Max-Age → a SESSION cookie: it dies with the tab
+ // and, combined with the pagehide handler below, is cleared the
+ // moment you leave the page. Admin powers never silently
+ // outlive the page you armed them on.
+ document.cookie = COOKIE_NAME + '=1; Path=/; SameSite=Lax';
} else {
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() {
try {
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;
}
- // handleAdminParam applies a ?admin= request. Returns true when a
- // navigation (reload) is underway so the caller can stop. Enabling is
- // gated on can_elevate — a non-admin who types ?admin=true just gets
- // the param stripped, never a misleading red border. Disabling is open
- // (anyone may drop a cookie they somehow hold).
- async function handleAdminParam() {
+ // handleAdminParam applies a ?admin= request IN PLACE (no reload — see
+ // the module header on why reloads would race the pagehide-clear).
+ // Enabling is gated on can_elevate — a non-admin who types ?admin=true
+ // just gets the param stripped, never a misleading red border.
+ // Disabling is open (anyone may drop a cookie they somehow hold).
+ // `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();
- if (want === null) return false;
+ if (want === null) return;
var clean = urlWithoutAdmin();
- if (want === isElevated()) {
- // Already in the requested state — just clean the URL, no reload.
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
+ try { history.replaceState(history.state, '', clean); } catch (_e) {}
+ if (want === isElevated()) return; // already in the requested state
if (want === true) {
- var access = await fetchAccess();
- if (!access || !access.can_elevate) {
- try { history.replaceState(history.state, '', clean); } catch (_e) {}
- return false;
- }
- setElevated(true);
+ if (access === undefined) access = await fetchAccess();
+ if (!access || !access.can_elevate) return; // silent no-op
+ setOn();
} 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
@@ -13556,10 +13657,7 @@ X.B(E,Y);return E}return J}())
+ '';
document.body.insertBefore(banner, document.body.firstChild);
var off = banner.querySelector('#elevation-banner-off');
- if (off) off.addEventListener('click', function () {
- setElevated(false);
- window.location.reload();
- });
+ if (off) off.addEventListener('click', function () { setOff(); });
}
} else if (banner) {
banner.parentNode.removeChild(banner);
@@ -13567,16 +13665,30 @@ X.B(E,Y);return E}return J}())
}
async function init() {
- // Apply (or tear down) the red border + banner from the cookie on
- // every page load — admin mode is toggled by URL, but the armed
- // chrome must surface everywhere so the user can't accidentally
- // write through an elevated context on a page they didn't toggle.
+ // file:// (offline FS-Access mode) has no server to elevate against.
+ if (window.location.protocol === 'file:') return;
+
+ // 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());
- // Honour ?admin=true|false typed into any zddc-server URL. There's
- // no on-screen toggle anymore — the URL is the enable path and the
- // red banner's "Drop admin" button is the one-click disable.
+ // Honour ?admin=true|false typed into any URL — handleAdminParam
+ // fetches /.profile/access itself to gate arming on can_elevate. The
+ // 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();
+
+ // 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') {
@@ -13585,7 +13697,178 @@ X.B(E,Y);return E}return J}())
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 , 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.
diff --git a/zddc/internal/apps/embedded/versions.txt b/zddc/internal/apps/embedded/versions.txt
index 9b74c09..379dc0b 100644
--- a/zddc/internal/apps/embedded/versions.txt
+++ b/zddc/internal/apps/embedded/versions.txt
@@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One = per line.
-archive=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
-transmittal=v0.0.27-beta · 2026-06-05 12:41:16 · 382645b
-classifier=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
-landing=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
-form=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
-tables=v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
-browse=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-08 11:50:20 · 0d7feb3
+classifier=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
+landing=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
+form=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
+tables=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
+browse=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html
index 4b00051..341f769 100644
--- a/zddc/internal/handler/tables.html
+++ b/zddc/internal/handler/tables.html
@@ -1648,7 +1648,7 @@ body.is-elevated::after {