diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 15a9f0d..2f5a2ff 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.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. */ @@ -2582,18 +2665,12 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.27-beta · 2026-06-05 12:41:17 · 382645b + v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
- -
@@ -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 {
ZDDC Browse - v0.0.27-beta · 2026-06-05 12:41:17 · 382645b + v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
- -
@@ -2590,18 +2677,10 @@ 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 = '
' + + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
'; + return; + } + + var data = {}; + try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; } + if (typeof data !== 'object' || Array.isArray(data)) data = {}; + await loadSchema(); + + var editable = canSave(node); + current = { node: node, dirty: false, etag: etag, lastModified: lastModified }; + + container.innerHTML = ''; + var shell = el('div', 'yaml-shell zddc-form'); + shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;'; + container.appendChild(shell); + + // Header. + var hdr = el('div', 'md-shell__infohdr'); + hdr.appendChild(el('span', 'md-shell__title', node.name)); + var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only')); + hdr.appendChild(srcTag); + var dirtyEl = el('span', 'md-shell__dirty'); + hdr.appendChild(dirtyEl); + var statusEl = el('span', 'md-shell__status'); + hdr.appendChild(statusEl); + var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML'); + rawBtn.type = 'button'; + rawBtn.title = 'Switch to the raw YAML editor (covers every key).'; + rawBtn.addEventListener('click', function () { + var ym = window.app.modules.yamledit; + if (ym && ym.render) { dispose(); ym.render(node, container, ctx); } + }); + hdr.appendChild(rawBtn); + var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save'); + saveBtn.type = 'button'; + saveBtn.disabled = true; + hdr.appendChild(saveBtn); + shell.appendChild(hdr); + + function markDirty() { + if (!current) return; + current.dirty = true; + dirtyEl.textContent = '● modified'; + if (editable) saveBtn.disabled = false; + } + + var help = el('p', 'help'); + help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;'; + help.textContent = editable + ? 'Project options. Structural keys are read-only — use Edit raw YAML.' + : 'Read-only — you need admin authority over this path to edit it.'; + shell.appendChild(help); + + // ── OPTION fields ─────────────────────────────────────────────────── + function section(title, hint, tight) { + var s = el('section', 'zf-section'); + s.style.cssText = 'margin:0 0 1rem;'; + var h = el('h3', null, title); + // `tight` drops the heading's top margin for the FIRST section so + // it doesn't stack with the intro's bottom margin (the gap above + // Title was reading as excessive). Later sections keep the gap. + h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;'; + s.appendChild(h); + if (hint) { + var p = el('p', 'help', hint); + p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;'; + s.appendChild(p); + } + shell.appendChild(s); + return s; + } + + // title + var titleSec = section('Title', desc('title'), true); + var titleInput = el('input'); + titleInput.type = 'text'; + titleInput.value = (typeof data.title === 'string') ? data.title : ''; + titleInput.disabled = !editable; + titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;'; + titleInput.addEventListener('input', markDirty); + titleSec.appendChild(titleInput); + + // admins + var adminsSec = section('Admins', desc('admins')); + var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable); + adminsSec.appendChild(adminsList); + + // roles (map name → {members:[]}) + var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.'); + var rolesHost = el('div', 'zf-roles'); + rolesSec.appendChild(rolesHost); + var roleEditors = []; // {name, membersEl, getName} + function addRole(name, members) { + var box = el('div', 'zf-role'); + box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;'; + var nameRow = el('div'); + nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;'; + var nameInput = el('input'); + nameInput.type = 'text'; + nameInput.value = name || ''; + nameInput.placeholder = 'role name (e.g. document_controller)'; + nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;'; + nameInput.disabled = !editable; + nameInput.addEventListener('input', markDirty); + nameRow.appendChild(el('span', null, '👥')); + nameRow.appendChild(nameInput); + box.appendChild(nameRow); + var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable); + box.appendChild(membersList); + rolesHost.appendChild(box); + roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList }); + } + var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {}; + Object.keys(roles).forEach(function (rn) { + var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : []; + addRole(rn, m); + }); + if (editable) { + var addRoleBtn = el('button', 'zf-add', '+ add role'); + addRoleBtn.type = 'button'; + addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); }); + rolesSec.appendChild(addRoleBtn); + } + + // ── STRUCTURE (read-only) ─────────────────────────────────────────── + var structKeys = Object.keys(data).filter(function (k) { + return schemaProps[k] && schemaProps[k].tier === 'structure'; + }); + // Also surface option keys this form doesn't render yet, as read-only. + var rawHandled = { title: 1, admins: 1, roles: 1 }; + var otherKeys = Object.keys(data).filter(function (k) { + return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure'); + }); + if (structKeys.length || otherKeys.length) { + var det = el('details', 'zf-structure'); + det.style.cssText = 'margin-top:.5rem;'; + var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)'); + sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;'; + det.appendChild(sum); + var subset = {}; + structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; }); + var pre = el('pre'); + pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;'; + try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); } + catch (_) { pre.textContent = JSON.stringify(subset, null, 2); } + det.appendChild(pre); + shell.appendChild(det); + } + + // ── Save ──────────────────────────────────────────────────────────── + function buildContent() { + var out = {}; + // Preserve everything not managed by the form (structure + unrendered options). + Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; }); + var t = titleInput.value.trim(); + if (t) out.title = t; + var admins = adminsList._values(); + if (admins.length) out.admins = admins; + var rolesOut = {}; + roleEditors.forEach(function (re) { + var n = re.getName(); + if (!n) return; + var mem = re.members._values(); + rolesOut[n] = mem.length ? { members: mem } : { members: [] }; + }); + if (Object.keys(rolesOut).length) out.roles = rolesOut; + return window.jsyaml.dump(out); + } + + saveBtn.addEventListener('click', async function () { + if (!current || !editable) return; + saveBtn.disabled = true; + statusEl.textContent = 'Saving…'; + var content; + try { content = buildContent(); } + catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; } + try { + var res = await saveFile(node, content, 'application/yaml; charset=utf-8', + { etag: current.etag, lastModified: current.lastModified }); + if (!current) return; + current.etag = (res && res.etag) || current.etag; + current.dirty = false; + dirtyEl.textContent = ''; + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success'); + } catch (e) { + if (e && e.status === 412 && window.app.modules.conflict) { + window.app.modules.conflict.open({ + name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); } + }); + statusEl.textContent = 'Conflict — changed on server.'; + } else { + statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e); + } + saveBtn.disabled = false; + } + }); + } + + app.modules.zddcform = { + handles: handles, + render: render, + isDirty: isDirty, + currentNode: currentNode, + dispose: dispose + }; +})(window.app); + // hovercard.js — rich-metadata tooltip for tree rows. // // Replaces the native title="…" attribute with a custom card that @@ -11350,51 +12193,56 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.app.modules.hovercard = { hide: hide }; })(); -// grid.js — "Grid mode" plugin for browse. Loads the classifier tool -// as an iframe scoped to the current directory so users get classifier's -// full bulk-rename workflow without leaving browse. +// grid.js — in-pane tool embed for browse (the browse-as-shell bridge; see +// ARCHITECTURE.md's ADR). Loads a heavy, full-page tool as an iframe scoped to +// the current directory so the user gets that tool's full workflow without +// leaving the browse shell. browse stays the top-level app; the cascade's +// default_tool decides which tool embeds here. // -// Availability: the cascade decides. Grid auto-activates wherever the -// .zddc cascade resolves default_tool=classifier (defaults.zddc.yaml -// declares this for archive//incoming/). Operators can extend -// — e.g. setting default_tool=classifier on a custom dir activates -// grid mode there too — without touching this code. -// -// Iframe src resolution: /classifier.html. Iframe -// embedding only works in server mode; file:// pages don't get the -// Grid toggle. +// Availability: the cascade decides — `state.scopeDefaultTool` (the +// X-ZDDC-Default-Tool header) must name one of the EMBEDDABLE full-page tools: +// classifier (archive//incoming/), transmittal (…/staging/), archive +// (the archive index). tables/forms embed in the preview pane instead +// (table-leaf / form view); landing/browse don't self-embed. Operators extend +// by setting default_tool on a dir — no code change. Iframe src: +// /.html. Server mode only (file:// has no server). (function () { 'use strict'; var state = window.app.state; var mounted = false; - function classifierAvailableHere() { - // state.scopeDefaultTool is set by the loader from the - // X-ZDDC-Default-Tool response header on every listing fetch. - // Grid mode is meaningful exactly where the cascade picks - // classifier as the default — no client-side path matching. - return state.scopeDefaultTool === 'classifier'; + // Full-page tools that embed in the gridView pane when they're the dir's + // default_tool. (tables/form embed in the preview pane; landing/browse are + // not in-pane embeds.) + var EMBEDDABLE = { classifier: 1, transmittal: 1, archive: 1 }; + + // The cascade-resolved default tool for the current dir when it's an + // embeddable full-page tool; "" otherwise. + function embedToolHere() { + var t = state.scopeDefaultTool; + return (t && EMBEDDABLE[t]) ? t : ''; } function activate() { var host = document.getElementById('gridView'); if (!host) return; if (mounted) return; - if (state.source !== 'server' || !classifierAvailableHere()) return; + var tool = embedToolHere(); + if (state.source !== 'server' || !tool) return; - // Compute the iframe src: current page's directory + classifier.html. + // Compute the iframe src: current page's directory + .html. var pathname = window.location.pathname || '/'; if (!pathname.endsWith('/')) { var lastSlash = pathname.lastIndexOf('/'); pathname = lastSlash >= 0 ? pathname.substring(0, lastSlash + 1) : '/'; } - var src = pathname + 'classifier.html'; + var src = pathname + tool + '.html'; host.innerHTML = ''; var frame = document.createElement('iframe'); frame.src = src; - frame.title = 'ZDDC Classifier (Grid mode)'; + frame.title = 'ZDDC ' + tool; frame.style.cssText = 'width:100%;height:100%;border:0;display:block;' + 'background:var(--bg);'; host.appendChild(frame); @@ -11413,10 +12261,13 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.app.modules.grid = { activate: activate, reset: reset, - // Hook for events.js to show/hide the Grid toggle button. + // Hook for events.js's view-mode resolution: is an embeddable tool the + // default here? availableHere: function () { - return state.source === 'server' && classifierAvailableHere(); - } + return state.source === 'server' && !!embedToolHere(); + }, + // The embeddable tool name (or "") — lets the shell label the view. + toolHere: embedToolHere }; })(); @@ -12043,6 +12894,32 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr function events() { return window.app.modules.events; } + // Canonical document-conversion matrix — mirrors zddc/internal/convert + // Convert(): which target formats a given source extension can be exported + // to. PDF is markdown-only (md→pdf) because the server has no docx→pdf / + // html→pdf path. This is the SINGLE source of truth for both the Export + // context-menu (download.exportTargets) and the markdown editor's + // DOCX/HTML/PDF buttons (preview-markdown.js), so the two never drift. + var EXPORT_MATRIX = { + md: ['docx', 'html', 'pdf'], + docx: ['md', 'html'], + html: ['md', 'docx'] + }; + + // exportTargets returns the formats a file of extension `ext` can be + // exported to (excludes the source format itself), or [] if `ext` is not a + // convertible source. Case-insensitive. + function exportTargets(ext) { + return EXPORT_MATRIX[String(ext || '').toLowerCase()] || []; + } + + // convertUrl maps a source path/URL to its sibling virtual-conversion URL + // (foo.md → foo.pdf). zddc-server recognises the sibling-extension pattern + // and converts on the fly. Shared by exportFile and the editor buttons. + function convertUrl(path, fmt) { + return String(path || '').replace(/\.[^./]+$/, '') + '.' + fmt; + } + function isHiddenName(name) { return name.length === 0 || name[0] === '.' || name[0] === '_'; } @@ -12221,8 +13098,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr events().statusError('No path for ' + node.name); return; } - var url = path.replace(/\.[^./]+$/, '') + '.' + fmt; - var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt; + var url = convertUrl(path, fmt); + var name = convertUrl(node.name, fmt); events().statusInfo('Exporting ' + name + '…'); downloadUrl(name, url); setTimeout(function () { events().statusClear(); }, 2500); @@ -12231,7 +13108,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.app.modules.download = { downloadFile: downloadFile, downloadFolder: downloadFolder, - exportFile: exportFile + exportFile: exportFile, + exportTargets: exportTargets, + convertUrl: convertUrl }; })(); @@ -13775,21 +14654,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr refresh.classList.add('hidden'); } } - // Toolbar New buttons: enabled when there's a writable target, and in - // server mode greyed (with a why-tooltip) when the scope lacks the - // create verb. Mirrors the menu's create-gate. - var canCreate = canCreateHere(); - var lacksCreateVerb = state.source === 'server' - && state.scopeAccess && typeof state.scopeAccess.path_verbs === 'string' - && state.scopeAccess.path_verbs.indexOf('c') === -1; - ['newFolderBtn', 'newFileBtn'].forEach(function (id) { - var b = document.getElementById(id); - if (!b) return; - var off = !canCreate || lacksCreateVerb; - b.disabled = off; - b.title = lacksCreateVerb ? 'You don’t have create access here.' - : (!canCreate ? 'Open a folder to create files here.' : ''); - }); } // syncURLToSelection reflects the current scope + selected node + @@ -13912,18 +14776,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var refresh = document.getElementById('refreshHeaderBtn'); if (refresh) refresh.addEventListener('click', refreshListing); - // ── Tree-pane toolbar: New folder / New file, Sort, Show hidden ── - // View settings live on the toolbar (not in per-row right-click - // menus); create has a discoverable affordance here now that file - // rows no longer offer it. - var newFolderBtn = document.getElementById('newFolderBtn'); - if (newFolderBtn) newFolderBtn.addEventListener('click', function () { - createInDir(state.currentPath || '/', 'folder'); - }); - var newFileBtn = document.getElementById('newFileBtn'); - if (newFileBtn) newFileBtn.addEventListener('click', function () { - createInDir(state.currentPath || '/', 'markdown'); + // Admin mode (shared/elevation.js) flipped on this page. Listing + // verbs + editor affordances (canSave) are computed against the + // server WITH the elevation cookie, so re-fetch the listing (which + // re-runs prefetchScopeAccess) and re-render the open preview — + // restoreState only restores the highlight, not the pane contents. + window.addEventListener('zddc:elevationchange', async function () { + if (state.source !== 'server') return; // FS mode has no server elevation + await refreshListing(); + var node = state.lastPreviewedNodeId && state.nodes.get(state.lastPreviewedNodeId); + var p = window.app.modules.preview; + if (node && !node.isDir && p && p.showFilePreview) p.showFilePreview(node); }); + + // ── Tree-pane toolbar: Sort + Show hidden ────────────────────── + // View settings only. Create actions (new folder / file) live in + // the right-click context menu, not the toolbar. var sortSelect = document.getElementById('sortSelect'); if (sortSelect) { // Reflect current state, then drive setSortExplicit on change. @@ -14051,7 +14919,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var node = state.nodes.get(id); if (!node) return; - var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true'; + // Table-leaf dirs (mdl/rsk/ssr) are NOT expandable — they fall + // through to the preview path, which opens the tables tool. + var isExpandable = (row.dataset.isdir === 'true' || row.dataset.iszip === 'true') + && row.dataset.tableleaf !== 'true'; if (isExpandable) { e.preventDefault(); @@ -14117,6 +14988,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var row = e.target.closest('.tree-row'); if (!row) return; if (row.dataset.isdir !== 'true') return; + if (row.dataset.tableleaf === 'true') return; // leaf: single-click previews var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; @@ -14188,7 +15060,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var curIdx = visible.indexOf(state.selectedId); var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null; - var expandable = !!(node && (node.isDir || node.isZip)); + // Table-leaf dirs aren't expandable: Enter/Space previews them + // (opens the table) rather than toggling. + var expandable = !!(node && (node.isDir || node.isZip) + && !window.app.modules.util.isTableLeaf(node)); var nextId = null; var previewModule = previewMod(); @@ -14812,11 +15687,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // View mode is URL-driven, not UI-driven. // - // ?view=grid → grid mode (only honored where classifier is - // available; otherwise falls back to browse) - // ?view=browse → browse mode (always) - // default → path-based: grid when inside an incoming/ - // subtree, browse everywhere else + // ?view=grid → embedded-tool view (only honored where the cascade's + // default_tool is an embeddable full-page tool — + // classifier/transmittal/archive; else falls back to browse) + // ?view=browse → browse listing (always) + // default → embedded-tool view when the dir's default_tool is one + // of those tools, browse listing everywhere else // // resolveViewMode reads the current location and returns the mode // to render; applyResolvedViewMode toggles the panes accordingly. @@ -14825,10 +15701,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var qs = new URLSearchParams(window.location.search); var explicit = (qs.get('view') || '').toLowerCase(); var grid = window.app.modules.grid; - var classifierHere = !!(grid && grid.availableHere && grid.availableHere()); - if (explicit === 'grid') return classifierHere ? 'grid' : 'browse'; + var toolHere = !!(grid && grid.availableHere && grid.availableHere()); + if (explicit === 'grid') return toolHere ? 'grid' : 'browse'; if (explicit === 'browse') return 'browse'; - return classifierHere ? 'grid' : 'browse'; + return toolHere ? 'grid' : 'browse'; } function applyResolvedViewMode() { diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 0202715..b7e32f5 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.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. */ @@ -1793,18 +1876,12 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.27-beta · 2026-06-05 12:41:17 · 382645b + v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
- -
@@ -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 {
ZDDC - v0.0.27-beta · 2026-06-05 12:41:17 · 382645b + v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
- -
@@ -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 {
ZDDC Transmittal - v0.0.27-beta · 2026-06-05 12:41:16 · 382645b + v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
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 {
ZDDC Table - v0.0.27-dev · 2026-06-07 13:11:11 · 1b9fec6-dirty + v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3