diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 0a7cced..0ff3b08 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -269,7 +269,7 @@ a:hover { } /* Subdued / de-emphasized variant. - Used on the "Add Local Directory" button when a tool is operating + Used on the "Use Local Directory" button when a tool is operating in server (online) mode — the local-dir affordance is still available but visually quieter, since the typical user already has the directory loaded from the server. */ @@ -331,6 +331,11 @@ a:hover { background: var(--bg-secondary); border-bottom: 1px solid var(--border); flex-shrink: 0; + /* Let the left / right groups wrap to a second row at narrow + viewports rather than overflowing the viewport edge. row-gap + gives a small breathing strip when wrapped. */ + flex-wrap: wrap; + row-gap: 0.3rem; } /* Left and right groups inside .app-header. Both flex-row so their @@ -342,16 +347,35 @@ a:hover { display: flex; align-items: center; gap: 0.75rem; + /* Allow the title to shrink (and ellipsize) before the action + buttons get pushed off-screen at narrow viewports. */ + min-width: 0; + flex-wrap: wrap; + row-gap: 0.3rem; } .header-right { display: flex; align-items: center; gap: 0.5rem; + flex-shrink: 0; +} + +/* Title group (title + build label). Made shrinkable so narrow + viewports don't push the action buttons out of view; the title + itself ellipsizes via the rule below. */ +.header-title-group { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; + flex-shrink: 1; } /* Tool name inside the header. Renders in the display serif so the - tool's identity reads as a document title, not a UI label. */ + tool's identity reads as a document title, not a UI label. + overflow + ellipsis on min-width:0 lets the title compress + gracefully when there's no room. */ .app-header__title { font-family: var(--font-display); font-size: 18px; @@ -359,6 +383,9 @@ a:hover { color: var(--text); letter-spacing: 0; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } /* Brand logo — sits left of the title in every tool's app-header. @@ -809,61 +836,127 @@ body.help-open .app-header { to { transform: translateX(100%); opacity: 0; } } -/* shared/nav.css — lateral project-stage strip paired with shared/nav.js. - Sits as a sibling immediately under .app-header (mounted by JS). - Rendered only in online mode when a project segment is in the URL. */ +/* shared/elevation.css — admin-elevation toggle in the tool header. + Renders only for users with admin scope (handled by elevation.js; + the placeholder is `.hidden` by default). When visible, sits left + of the theme button — sudo-style affordance for opting into admin + powers. */ -.zddc-stage-strip { - display: flex; +.elevation-toggle { + display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.3rem 1rem; - background: var(--bg); - border-bottom: 1px solid var(--border); - font-size: 0.8rem; - line-height: 1.3; - flex-shrink: 0; - overflow-x: auto; - white-space: nowrap; -} - -.zddc-stage-strip__project { - color: var(--text); - font-weight: 600; - margin-right: 0.15rem; -} - -.zddc-stage-strip__divider, -.zddc-stage-strip__sep { + gap: 0.3rem; + font-size: 0.78rem; color: var(--text-muted); user-select: none; -} - -.zddc-stage-strip__divider { - margin-right: 0.35rem; -} - -.zddc-stage { - color: var(--text-muted); - text-decoration: none; - padding: 0.1rem 0.25rem; + cursor: pointer; + padding: 0.15rem 0.45rem; + border: 1px solid var(--border); border-radius: var(--radius); - transition: color 0.15s, background 0.15s; + background: var(--bg); + transition: background 0.12s, border-color 0.12s, color 0.12s; } -.zddc-stage:hover { - color: var(--text); - background: var(--bg-secondary); - text-decoration: none; +.elevation-toggle:hover { + background: var(--bg-hover); + border-color: var(--border-dark); } -.zddc-stage--active { - color: var(--primary); +.elevation-toggle input[type="checkbox"] { + margin: 0; + cursor: pointer; + accent-color: var(--danger); +} + +.elevation-toggle__label { + cursor: pointer; + letter-spacing: 0.02em; +} + +/* Active state — when elevation is ON, the toggle reads as "armed" + so the user can't miss that admin powers are currently live. + :has(:checked) lets us style the wrapper based on the inner + checkbox without JS. */ +.elevation-toggle:has(input:checked) { + background: rgba(220, 53, 69, 0.12); + border-color: var(--danger); + color: var(--danger); font-weight: 600; } -.zddc-stage--active:hover { - color: var(--primary); +/* Page-wide chrome when admin mode is active. The toggle alone is + easy to miss; these add an inescapable visual cue: + 1. Thin red border around the entire viewport — peripheral- + vision reminder regardless of which tool / scroll position. + 2. Sticky banner across the top with a one-click "Drop admin" + button so the user can disarm without hunting for the toggle. + Both rendered ONLY when the zddc-elevate cookie is set; the + shared/elevation.js init() syncs the body class on every page + load and tears it down when elevation is cleared. + + Frame uses fixed positioning + pointer-events:none so it doesn't + reflow content or steal clicks. An inset outline on was + tried first but overdrew content in tools whose root layout butts + right up to the viewport edge (browse split-pane, archive grid). */ +body.is-elevated::after { + content: ""; + position: fixed; + inset: 0; + border: 3px solid var(--danger, #dc3545); + pointer-events: none; + z-index: 9200; /* above banner (9100) so the frame paints on top */ +} + +.elevation-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.9rem; + background: rgba(220, 53, 69, 0.95); + color: #fff; + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.01em; + position: sticky; + top: 0; + z-index: 9100; /* above modal-overlay (9000) so it's never hidden */ + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); +} + +.elevation-banner__dot { + width: 0.5rem; + height: 0.5rem; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); + animation: elev-pulse 1.6s infinite; + flex-shrink: 0; +} + +@keyframes elev-pulse { + 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); } + 70% { box-shadow: 0 0 0 8px rgba(255, 255, 255, 0); } + 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } +} + +.elevation-banner__msg { + flex: 1 1 auto; +} + +.elevation-banner__off { + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.7); + color: #fff; + padding: 0.18rem 0.65rem; + border-radius: var(--radius, 4px); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + flex-shrink: 0; +} +.elevation-banner__off:hover { + background: rgba(255, 255, 255, 0.3); } /* shared/logo.css — paired with shared/logo.js. The wrapping anchor @@ -2470,13 +2563,19 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 + v0.0.17
- +
- + + +
@@ -2680,7 +2779,7 @@ td[data-field="trackingNumber"] {

Welcome to ZDDC Archive

-

Click Add Local Directory to select an archive folder to browse.

+

Click Use Local Directory to select an archive folder to browse.

This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.

How to navigate:

+ +
@@ -1685,7 +2375,7 @@ html, body {

Once loaded: click folders to expand, click files to preview them in @@ -1698,33 +2388,20 @@ html, body {

+ +
@@ -4202,6 +4301,7 @@ X.B(E,Y);return E}return J}()) 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', + 'TBD', ]; var STATUS_SET = {}; @@ -4930,13 +5030,33 @@ X.B(E,Y);return E}return J}()) // Top-level helpers // ----------------------------------------------------------------- - // Strip a trailing tool .html (e.g. classifier.html) from a path - // to land on the "directory the tool was opened in". + // Resolve "the directory the tool was opened in" for the current + // page URL. Two URL shapes serve a tool: + // + // /…/.html — file URL; strip the trailing filename. + // /…// — trailing-slash directory URL; keep it. + // /…/ — bare-directory URL served by the + // cascade's `default_tool` (e.g. + // archive//mdl serves the tables + // tool). Treat as the directory itself + // and append the missing slash. + // + // Discrimination is "does the last segment contain a dot?" — a dot + // is a reliable proxy for "looks like a file with an extension" + // since neither directory names nor default_tool paths contain + // them in this system. function pathToDir(pathname) { if (!pathname) return '/'; if (pathname.endsWith('/')) return pathname; var slash = pathname.lastIndexOf('/'); - return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; + var lastSeg = slash >= 0 ? pathname.substring(slash + 1) : pathname; + if (lastSeg.indexOf('.') !== -1) { + // Has an extension → looks like a file URL → strip the + // filename to land on the parent directory. + return slash >= 0 ? pathname.substring(0, slash + 1) : '/'; + } + // No extension → the URL IS the directory; just close it. + return pathname + '/'; } // Probe the server-mode root for the current page. Returns: @@ -5016,9 +5136,14 @@ X.B(E,Y);return E}return J}()) // srcUrl points at the .md source on the server. fmt is one of // "docx" | "html" | "pdf". The server response status maps to a // friendly error message for the caller to surface (toast / status). + // + // URL grammar: srcUrl is the `.md` source; the converted + // form lives at `.` (virtual file extension recognised + // by zddc-server's dispatcher). Replaces the older `?convert=` + // query form. async function downloadConverted(srcUrl, fileName, fmt) { - var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt), - { credentials: 'same-origin' }); + var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt; + var resp = await fetch(convertUrl, { credentials: 'same-origin' }); if (!resp.ok) { var msg; if (resp.status === 503) msg = 'Conversion service unavailable on this server.'; @@ -5219,211 +5344,6 @@ X.B(E,Y);return E}return J}()) } })(); -// shared/nav.js — lateral navigation strip across the project's -// cascade-declared stages. Mounted as a sibling of
on DOMContentLoaded, hydrated from the project root's -// directory listing. -// -// Stage discovery is cascade-driven (Phase 4c): fetch the project -// root's JSON listing, filter to entries with `declared: true` -// (server stamps these from the .zddc cascade's paths: tree), and -// render in canonical workflow order with display_name overrides -// honored. An operator who edits the project's .zddc paths: to add -// a new declared child sees it in the strip; one who removes a -// canonical entry sees the strip drop it. -// -// When the fetch fails (offline / no-server / file://), the strip -// falls back to the hardcoded four-stage list so existing -// deployments don't lose chrome. Hardcoded labels in this file are -// the LAST resort — the cascade is the source of truth in normal -// operation. -// -// Stage URLs follow the slash/no-slash convention: no slash opens -// the stage's default tool. Operators on non-standard layouts can -// override by setting window.zddc.nav.disabled = true before -// DOMContentLoaded. -(function () { - 'use strict'; - - if (!window.zddc) window.zddc = {}; - if (window.zddc.nav) return; // already loaded - - // Hardcoded fallback for offline / file:// / fetch-error contexts. - // Server-driven discovery (FETCH_STAGES below) is the normal path. - var FALLBACK_STAGES = [ - { name: 'archive', label: 'Archive' }, - { name: 'working', label: 'Working' }, - { name: 'staging', label: 'Staging' }, - { name: 'reviewing', label: 'Reviewing' }, - ]; - - // Canonical workflow order. Stages appearing in this list are - // rendered in this order; any extras the cascade declares are - // appended alphabetically. - var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing']; - - function projectSegment(pathname) { - var parts = pathname.split('/').filter(Boolean); - if (parts.length === 0) return null; - var first = parts[0]; - if (first.indexOf('.') !== -1) return null; - return first; - } - - function currentStage(pathname, stages) { - var parts = pathname.split('/').filter(Boolean); - if (parts.length < 2) return null; - var second = parts[1]; - for (var i = 0; i < stages.length; i++) { - if (second.toLowerCase() === stages[i].name.toLowerCase()) { - return stages[i].name; - } - } - if (second === 'archive.html') return 'archive'; - return null; - } - - function shouldRender() { - if (typeof location === 'undefined') return false; - if (location.protocol !== 'http:' && location.protocol !== 'https:') return false; - if (window.zddc.nav && window.zddc.nav.disabled) return false; - return projectSegment(location.pathname) !== null; - } - - function titleCase(s) { - if (!s) return s; - return s.charAt(0).toUpperCase() + s.slice(1); - } - - function sortByWorkflow(stages) { - return stages.slice().sort(function (a, b) { - var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase()); - var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase()); - if (ia >= 0 && ib >= 0) return ia - ib; - if (ia >= 0) return -1; - if (ib >= 0) return 1; - return a.name.localeCompare(b.name); - }); - } - - // Fetch the project root listing and extract declared stage - // entries. Returns [] on any error so callers fall back to the - // hardcoded list. Each stage entry is {name, label} — label - // honors the cascade's display: override when present. - async function fetchStagesFor(project) { - try { - var resp = await fetch('/' + encodeURIComponent(project) + '/', { - headers: { 'Accept': 'application/json' }, - credentials: 'same-origin', - }); - if (!resp.ok) return []; - var data = await resp.json(); - if (!Array.isArray(data)) return []; - var stages = []; - for (var i = 0; i < data.length; i++) { - var e = data[i]; - if (!e || !e.declared || !e.is_dir) continue; - var bare = (e.name || '').replace(/\/$/, ''); - if (!bare) continue; - stages.push({ - name: bare, - label: e.display_name || titleCase(bare), - }); - } - return sortByWorkflow(stages); - } catch (_e) { - return []; - } - } - - function buildStrip(project, active, stages) { - var nav = document.createElement('nav'); - nav.className = 'zddc-stage-strip'; - nav.setAttribute('aria-label', 'Project stage'); - - var label = document.createElement('span'); - label.className = 'zddc-stage-strip__project'; - label.textContent = project; - nav.appendChild(label); - - var sep0 = document.createElement('span'); - sep0.className = 'zddc-stage-strip__divider'; - sep0.setAttribute('aria-hidden', 'true'); - sep0.textContent = '/'; - nav.appendChild(sep0); - - for (var i = 0; i < stages.length; i++) { - var s = stages[i]; - var a = document.createElement('a'); - a.className = 'zddc-stage'; - a.href = '/' + encodeURIComponent(project) + '/' + s.name; - a.textContent = s.label; - if (s.name === active) { - a.classList.add('zddc-stage--active'); - a.setAttribute('aria-current', 'page'); - } - nav.appendChild(a); - - if (i < stages.length - 1) { - var sep = document.createElement('span'); - sep.className = 'zddc-stage-strip__sep'; - sep.setAttribute('aria-hidden', 'true'); - sep.textContent = '·'; - nav.appendChild(sep); - } - } - - return nav; - } - - function mountWith(project, stages) { - var header = document.querySelector('.app-header'); - if (!header) return; - if (header.previousElementSibling && - header.previousElementSibling.classList && - header.previousElementSibling.classList.contains('zddc-stage-strip')) { - return; // already mounted - } - var active = currentStage(location.pathname, stages); - var strip = buildStrip(project, active, stages); - header.parentNode.insertBefore(strip, header); - } - - async function mount() { - if (!shouldRender()) return; - var project = projectSegment(location.pathname); - if (!project) return; - - // Render the hardcoded fallback immediately so the strip - // appears with no flicker, then upgrade to cascade-resolved - // stages once the fetch completes. - mountWith(project, FALLBACK_STAGES); - - var fetched = await fetchStagesFor(project); - if (fetched.length === 0) return; // fetch failed → keep fallback - - // Replace the strip with the cascade-driven one. Remove the - // existing strip first so mountWith re-mounts cleanly. - var existing = document.querySelector('.zddc-stage-strip'); - if (existing && existing.parentNode) existing.parentNode.removeChild(existing); - mountWith(project, fetched); - } - - window.zddc.nav = { - mount: mount, - _projectSegment: projectSegment, - _currentStage: currentStage, - _fallbackStages: FALLBACK_STAGES, - disabled: false, - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', mount, { once: true }); - } else { - mount(); - } -})(); - // shared/logo.js — turn the inert ' + + '' + + 'Admin mode is on — write access bypasses WORM and ACL safeguards.' + + '' + + ''; + document.body.insertBefore(banner, document.body.firstChild); + var off = banner.querySelector('#elevation-banner-off'); + if (off) off.addEventListener('click', function () { + setElevated(false); + window.location.reload(); + }); + } + } else if (banner) { + banner.parentNode.removeChild(banner); + } + } + + async function init() { + // Body chrome applies on every page load whether or not the + // header has a toggle slot — the banner needs to surface in + // tools / pages that don't host the toggle (e.g. iframed + // classifier inside browse's grid mode), so the user can't + // accidentally write through an elevated context elsewhere. + applyArmedChrome(isElevated()); + + var host = document.getElementById('elevation-toggle'); + if (!host) return; // tool doesn't include the slot yet — no-op + var access = await fetchAccess(); + if (!access) return; // anonymous / endpoint missing — no-op + // Surface ONLY for users who have admin authority somewhere. + // /.profile/access ships `can_elevate` as an elevation- + // INDEPENDENT signal — true for any user named in any admin + // list, regardless of current cookie state. The other flags + // (is_super_admin, has_any_admin_scope) reflect EFFECTIVE + // authority and would be false for an un-elevated admin + // who hasn't toggled yet — so we can't gate on those. + if (!access.can_elevate) return; + render(host, isElevated()); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; +})(); + (function (app) { 'use strict'; diff --git a/zddc/internal/apps/embedded/versions.txt b/zddc/internal/apps/embedded/versions.txt index 1926b4e..ec5ba07 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.17-beta · 2026-05-13 19:45:42 · 72c0552 -transmittal=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 -classifier=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 -landing=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 -form=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 -tables=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 -browse=v0.0.17-beta · 2026-05-13 19:45:42 · 72c0552 +archive=v0.0.17 +transmittal=v0.0.17 +classifier=v0.0.17 +landing=v0.0.17 +form=v0.0.17 +tables=v0.0.17 +browse=v0.0.17 diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index fc53225..e4ff6d7 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1515,7 +1515,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.17-alpha · 2026-05-19 13:42:33 · 1721b4b-dirty + v0.0.17
@@ -5350,7 +5350,23 @@ body.is-elevated::after { // Success: clear drafts + invalid marks, capture new ETag. const newEtag = resp.headers.get('ETag'); if (newEtag) row.etag = newEtag.replace(/"/g, ''); - row.data = merged; + // For record-typed writes the server echoes the stamped + // YAML (with server-managed audit fields) back as the + // response body — parse it and overwrite row.data so the + // table sees the same bytes that just landed on disk. + // Falls back to the local merge when the server didn't + // echo a body (non-record write or older server). + let serverData = null; + const ct = (resp.headers.get('Content-Type') || '').toLowerCase(); + if (ct.includes('yaml') && window.jsyaml) { + try { + const text = await resp.text(); + if (text && text.trim()) serverData = window.jsyaml.load(text); + } catch (e) { + console.warn('[tables] server response YAML parse failed; using local merge', e); + } + } + row.data = serverData || merged; delete app.state.drafts[rowId]; clearCellInvalid(rowId); setRowState(rowId, ''); @@ -6753,7 +6769,16 @@ body.is-elevated::after { const help = (ui && ui['ui:help']) || ''; const placeholder = (ui && ui['ui:placeholder']) || ''; const widget = (ui && ui['ui:widget']) || ''; - const readonly = !!(ui && ui['ui:readonly']); + // readonly is honored from either source: an explicit UI override + // (ui:readonly: true) or the schema's readOnly field. The latter + // is set by the server when augmenting from cascade-locked + // records: entries and for audit fields declared readOnly in the + // *.form.yaml. + const readonly = !!(schema.readOnly) || !!(ui && ui['ui:readonly']); + // x-labels: { code → label } turns a bare enum into a labeled + // dropdown ("ACM — Acme Inc" rather than just "ACM"). Injected + // by the server from the cascade's field_codes:codes map. + const labels = (schema && schema['x-labels']) || null; const autofocus = !!(ui && ui['ui:autofocus']); let input; @@ -6799,17 +6824,22 @@ body.is-elevated::after { if (widget === 'radio') { input = u.h('div', { className: 'form-field__radio-group' }); opts.forEach(function (opt, idx) { + const codeStr = String(opt); const radioId = id + '-' + idx; - const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: String(opt) }); + const radio = u.h('input', { type: 'radio', name: id, id: radioId, value: codeStr }); if (value === opt) { radio.checked = true; } if (readonly) { radio.disabled = true; } + let displayText = codeStr; + if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) { + displayText = codeStr + ' — ' + labels[codeStr]; + } const lbl = u.h('label', { for: radioId }); lbl.appendChild(radio); - lbl.appendChild(document.createTextNode(' ' + String(opt))); + lbl.appendChild(document.createTextNode(' ' + displayText)); input.appendChild(lbl); }); read = function () { @@ -6822,7 +6852,12 @@ body.is-elevated::after { input.appendChild(u.h('option', { value: '' }, '— select —')); } opts.forEach(function (opt) { - const o = u.h('option', { value: String(opt) }, String(opt)); + const codeStr = String(opt); + let displayText = codeStr; + if (labels && Object.prototype.hasOwnProperty.call(labels, codeStr)) { + displayText = codeStr + ' — ' + labels[codeStr]; + } + const o = u.h('option', { value: codeStr }, displayText); if (value === opt) { o.selected = true; } @@ -6893,6 +6928,12 @@ body.is-elevated::after { if (autofocus) { input.autofocus = true; } + // Schema-driven HTML pattern attribute. Used as a UX hint + // only — authoritative validation runs server-side via the + // cascade's field_codes. + if (schema.pattern && input.tagName === 'INPUT') { + input.pattern = schema.pattern; + } read = function () { return input.value === '' ? undefined : input.value; };