ui: fix admin-mode frame; drop project-stage strip
Three UI cleanups against the admin/browse chrome.
Red admin-mode frame (shared/elevation.css)
Was: body { outline: 3px ... ; outline-offset: -3px } — an outline
doesn't reflow content, so in tools that butt their content to the
viewport edge (browse split-pane, archive grid) the frame painted
on top of the first 3px of content.
Now: body.is-elevated::after { position:fixed; inset:0; border:3px;
pointer-events:none; z-index:9200 }. The frame lives in its own
fixed layer above all content, so it never overlaps or steals
clicks; content layout is unchanged.
Project-stage strip (Archive · Working · Staging · Reviewing)
Low-value chrome. Removed entirely:
- delete shared/nav.js + shared/nav.css
- drop the include from every tool's build.sh
(browse, transmittal, form, archive, landing, tables, classifier)
- delete tests/nav.spec.js
- rebuild tables.html (the //go:embed'd baked-in copy)
Project navigation already happens through the directory tree in
browse and the URL bar; the strip duplicated breadcrumb information
without adding capability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cff840e225
commit
19566360a6
12 changed files with 29 additions and 638 deletions
2
archive/build.sh
Normal file → Executable file
2
archive/build.sh
Normal file → Executable file
|
|
@ -23,7 +23,6 @@ concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
"css/layout.css" \
|
"css/layout.css" \
|
||||||
|
|
@ -47,7 +46,6 @@ concat_files \
|
||||||
"../shared/zip-source.js" \
|
"../shared/zip-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ concat_files \
|
||||||
"../shared/fonts.css" \
|
"../shared/fonts.css" \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/vendor/toastui-editor.min.css" \
|
"../shared/vendor/toastui-editor.min.css" \
|
||||||
"../shared/vendor/codemirror-yaml.min.css" \
|
"../shared/vendor/codemirror-yaml.min.css" \
|
||||||
|
|
@ -51,7 +50,6 @@ concat_files \
|
||||||
"../shared/zip-source.js" \
|
"../shared/zip-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
|
|
|
||||||
2
classifier/build.sh
Normal file → Executable file
2
classifier/build.sh
Normal file → Executable file
|
|
@ -23,7 +23,6 @@ concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
"css/layout.css" \
|
"css/layout.css" \
|
||||||
|
|
@ -45,7 +44,6 @@ concat_files \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/form.css" \
|
"css/form.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -30,7 +29,6 @@ concat_files \
|
||||||
concat_files \
|
concat_files \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/landing.css" \
|
"css/landing.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -32,7 +31,6 @@ concat_files \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
|
|
||||||
|
|
@ -48,16 +48,25 @@
|
||||||
|
|
||||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
easy to miss; these add an inescapable visual cue:
|
easy to miss; these add an inescapable visual cue:
|
||||||
1. Thin red border around the entire page (body) — peripheral-
|
1. Thin red border around the entire viewport — peripheral-
|
||||||
vision reminder regardless of which tool / scroll position.
|
vision reminder regardless of which tool / scroll position.
|
||||||
2. Sticky banner across the top with a one-click "Drop admin"
|
2. Sticky banner across the top with a one-click "Drop admin"
|
||||||
button so the user can disarm without hunting for the toggle.
|
button so the user can disarm without hunting for the toggle.
|
||||||
Both rendered ONLY when the zddc-elevate cookie is set; the
|
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||||
shared/elevation.js init() syncs the body class on every page
|
shared/elevation.js init() syncs the body class on every page
|
||||||
load and tears it down when elevation is cleared. */
|
load and tears it down when elevation is cleared.
|
||||||
body.is-elevated {
|
|
||||||
outline: 3px solid var(--danger, #dc3545);
|
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||||
outline-offset: -3px;
|
reflow content or steal clicks. An inset outline on <body> was
|
||||||
|
tried first but overdrew content in tools whose root layout butts
|
||||||
|
right up to the viewport edge (browse split-pane, archive grid). */
|
||||||
|
body.is-elevated::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid var(--danger, #dc3545);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||||
}
|
}
|
||||||
|
|
||||||
.elevation-banner {
|
.elevation-banner {
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,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. */
|
|
||||||
|
|
||||||
.zddc-stage-strip {
|
|
||||||
display: 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 {
|
|
||||||
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;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage--active {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
204
shared/nav.js
204
shared/nav.js
|
|
@ -1,204 +0,0 @@
|
||||||
// shared/nav.js — lateral navigation strip across the project's
|
|
||||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
||||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
||||||
// directory listing.
|
|
||||||
//
|
|
||||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
||||||
// root's JSON listing, filter to entries with `declared: true`
|
|
||||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
||||||
// render in canonical workflow order with display_name overrides
|
|
||||||
// honored. An operator who edits the project's .zddc paths: to add
|
|
||||||
// a new declared child sees it in the strip; one who removes a
|
|
||||||
// canonical entry sees the strip drop it.
|
|
||||||
//
|
|
||||||
// When the fetch fails (offline / no-server / file://), the strip
|
|
||||||
// falls back to the hardcoded four-stage list so existing
|
|
||||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
||||||
// the LAST resort — the cascade is the source of truth in normal
|
|
||||||
// operation.
|
|
||||||
//
|
|
||||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
||||||
// the stage's default tool. Operators on non-standard layouts can
|
|
||||||
// override by setting window.zddc.nav.disabled = true before
|
|
||||||
// DOMContentLoaded.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.nav) return; // already loaded
|
|
||||||
|
|
||||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
||||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
||||||
var FALLBACK_STAGES = [
|
|
||||||
{ name: 'archive', label: 'Archive' },
|
|
||||||
{ name: 'working', label: 'Working' },
|
|
||||||
{ name: 'staging', label: 'Staging' },
|
|
||||||
{ name: 'reviewing', label: 'Reviewing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Canonical workflow order. Stages appearing in this list are
|
|
||||||
// rendered in this order; any extras the cascade declares are
|
|
||||||
// appended alphabetically.
|
|
||||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
||||||
|
|
||||||
function projectSegment(pathname) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
var first = parts[0];
|
|
||||||
if (first.indexOf('.') !== -1) return null;
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStage(pathname, stages) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
var second = parts[1];
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
||||||
return stages[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (second === 'archive.html') return 'archive';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRender() {
|
|
||||||
if (typeof location === 'undefined') return false;
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
||||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
||||||
return projectSegment(location.pathname) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s) {
|
|
||||||
if (!s) return s;
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByWorkflow(stages) {
|
|
||||||
return stages.slice().sort(function (a, b) {
|
|
||||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
||||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
||||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
||||||
if (ia >= 0) return -1;
|
|
||||||
if (ib >= 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the project root listing and extract declared stage
|
|
||||||
// entries. Returns [] on any error so callers fall back to the
|
|
||||||
// hardcoded list. Each stage entry is {name, label} — label
|
|
||||||
// honors the cascade's display: override when present.
|
|
||||||
async function fetchStagesFor(project) {
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
var stages = [];
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var e = data[i];
|
|
||||||
if (!e || !e.declared || !e.is_dir) continue;
|
|
||||||
var bare = (e.name || '').replace(/\/$/, '');
|
|
||||||
if (!bare) continue;
|
|
||||||
stages.push({
|
|
||||||
name: bare,
|
|
||||||
label: e.display_name || titleCase(bare),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sortByWorkflow(stages);
|
|
||||||
} catch (_e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrip(project, active, stages) {
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.className = 'zddc-stage-strip';
|
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'zddc-stage-strip__project';
|
|
||||||
label.textContent = project;
|
|
||||||
nav.appendChild(label);
|
|
||||||
|
|
||||||
var sep0 = document.createElement('span');
|
|
||||||
sep0.className = 'zddc-stage-strip__divider';
|
|
||||||
sep0.setAttribute('aria-hidden', 'true');
|
|
||||||
sep0.textContent = '/';
|
|
||||||
nav.appendChild(sep0);
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
var s = stages[i];
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'zddc-stage';
|
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
||||||
a.textContent = s.label;
|
|
||||||
if (s.name === active) {
|
|
||||||
a.classList.add('zddc-stage--active');
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
nav.appendChild(a);
|
|
||||||
|
|
||||||
if (i < stages.length - 1) {
|
|
||||||
var sep = document.createElement('span');
|
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
|
||||||
sep.textContent = '·';
|
|
||||||
nav.appendChild(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountWith(project, stages) {
|
|
||||||
var header = document.querySelector('.app-header');
|
|
||||||
if (!header) return;
|
|
||||||
if (header.previousElementSibling &&
|
|
||||||
header.previousElementSibling.classList &&
|
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
||||||
return; // already mounted
|
|
||||||
}
|
|
||||||
var active = currentStage(location.pathname, stages);
|
|
||||||
var strip = buildStrip(project, active, stages);
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
|
||||||
if (!shouldRender()) return;
|
|
||||||
var project = projectSegment(location.pathname);
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Render the hardcoded fallback immediately so the strip
|
|
||||||
// appears with no flicker, then upgrade to cascade-resolved
|
|
||||||
// stages once the fetch completes.
|
|
||||||
mountWith(project, FALLBACK_STAGES);
|
|
||||||
|
|
||||||
var fetched = await fetchStagesFor(project);
|
|
||||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
||||||
|
|
||||||
// Replace the strip with the cascade-driven one. Remove the
|
|
||||||
// existing strip first so mountWith re-mounts cleanly.
|
|
||||||
var existing = document.querySelector('.zddc-stage-strip');
|
|
||||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
||||||
mountWith(project, fetched);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.nav = {
|
|
||||||
mount: mount,
|
|
||||||
_projectSegment: projectSegment,
|
|
||||||
_currentStage: currentStage,
|
|
||||||
_fallbackStages: FALLBACK_STAGES,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
||||||
} else {
|
|
||||||
mount();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -22,7 +22,6 @@ concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/context-menu.css" \
|
"../shared/context-menu.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
|
|
@ -40,7 +39,6 @@ concat_files \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
// Tests for shared/nav.js — the lateral project-stage strip.
|
|
||||||
//
|
|
||||||
// The strip's render decision depends on location.protocol and
|
|
||||||
// location.pathname. file:// won't render at all (online-only). To
|
|
||||||
// exercise online behavior we spin up a tiny in-process HTTP server
|
|
||||||
// for this spec so the page can be served from http://127.0.0.1:<port>
|
|
||||||
// at arbitrary paths.
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import * as http from 'http';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const HTML_PATH = path.resolve('classifier/dist/classifier.html');
|
|
||||||
|
|
||||||
let server;
|
|
||||||
let baseUrl;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
const html = fs.readFileSync(HTML_PATH, 'utf8');
|
|
||||||
server = http.createServer((req, res) => {
|
|
||||||
// Serve the same classifier HTML at every path. The strip's
|
|
||||||
// detection logic uses location.pathname; the bytes don't have
|
|
||||||
// to vary.
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
||||||
res.end(html);
|
|
||||||
});
|
|
||||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
||||||
const port = server.address().port;
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
if (server) await new Promise(resolve => server.close(resolve));
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('shared/nav.js stage strip', () => {
|
|
||||||
|
|
||||||
test('does NOT render at the deployment root', async ({ page }) => {
|
|
||||||
await page.goto(`${baseUrl}/index.html`, { waitUntil: 'load' });
|
|
||||||
await page.waitForSelector('.app-header', { timeout: 5000 });
|
|
||||||
await expect(page.locator('.zddc-stage-strip')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders for <project>/archive.html with archive active', async ({ page }) => {
|
|
||||||
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
|
|
||||||
const strip = page.locator('.zddc-stage-strip');
|
|
||||||
await expect(strip).toHaveCount(1);
|
|
||||||
await expect(strip.locator('.zddc-stage-strip__project')).toHaveText('projA');
|
|
||||||
|
|
||||||
const stages = await strip.locator('.zddc-stage').allTextContents();
|
|
||||||
expect(stages).toEqual(['Archive', 'Working', 'Staging', 'Reviewing']);
|
|
||||||
|
|
||||||
const active = strip.locator('.zddc-stage--active');
|
|
||||||
await expect(active).toHaveCount(1);
|
|
||||||
await expect(active).toHaveText('Archive');
|
|
||||||
await expect(active).toHaveAttribute('aria-current', 'page');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
|
|
||||||
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
|
|
||||||
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
|
|
||||||
await expect(active).toHaveText('Working');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stage links point to the canonical <project>/<stage>/ URLs', async ({ page }) => {
|
|
||||||
await page.goto(`${baseUrl}/projA/staging/`, { waitUntil: 'load' });
|
|
||||||
await page.waitForSelector('.zddc-stage-strip');
|
|
||||||
|
|
||||||
const links = await page.evaluate(() => {
|
|
||||||
const xs = document.querySelectorAll('.zddc-stage-strip .zddc-stage');
|
|
||||||
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
|
|
||||||
});
|
|
||||||
expect(links).toEqual([
|
|
||||||
{ text: 'Archive', href: '/projA/archive' },
|
|
||||||
{ text: 'Working', href: '/projA/working' },
|
|
||||||
{ text: 'Staging', href: '/projA/staging' },
|
|
||||||
{ text: 'Reviewing', href: '/projA/reviewing' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mounts immediately above the app-header', async ({ page }) => {
|
|
||||||
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
|
|
||||||
const prev = await page.evaluate(() => {
|
|
||||||
const h = document.querySelector('.app-header');
|
|
||||||
return h && h.previousElementSibling && h.previousElementSibling.className;
|
|
||||||
});
|
|
||||||
expect(prev).toContain('zddc-stage-strip');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
@ -26,7 +26,6 @@ concat_files \
|
||||||
"../shared/base.css" \
|
"../shared/base.css" \
|
||||||
"../shared/toast.css" \
|
"../shared/toast.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/nav.css" \
|
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
"css/layout.css" \
|
"css/layout.css" \
|
||||||
|
|
@ -55,7 +54,6 @@ concat_files \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
"../shared/nav.js" \
|
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -886,16 +886,25 @@ body.help-open .app-header {
|
||||||
|
|
||||||
/* Page-wide chrome when admin mode is active. The toggle alone is
|
/* Page-wide chrome when admin mode is active. The toggle alone is
|
||||||
easy to miss; these add an inescapable visual cue:
|
easy to miss; these add an inescapable visual cue:
|
||||||
1. Thin red border around the entire page (body) — peripheral-
|
1. Thin red border around the entire viewport — peripheral-
|
||||||
vision reminder regardless of which tool / scroll position.
|
vision reminder regardless of which tool / scroll position.
|
||||||
2. Sticky banner across the top with a one-click "Drop admin"
|
2. Sticky banner across the top with a one-click "Drop admin"
|
||||||
button so the user can disarm without hunting for the toggle.
|
button so the user can disarm without hunting for the toggle.
|
||||||
Both rendered ONLY when the zddc-elevate cookie is set; the
|
Both rendered ONLY when the zddc-elevate cookie is set; the
|
||||||
shared/elevation.js init() syncs the body class on every page
|
shared/elevation.js init() syncs the body class on every page
|
||||||
load and tears it down when elevation is cleared. */
|
load and tears it down when elevation is cleared.
|
||||||
body.is-elevated {
|
|
||||||
outline: 3px solid var(--danger, #dc3545);
|
Frame uses fixed positioning + pointer-events:none so it doesn't
|
||||||
outline-offset: -3px;
|
reflow content or steal clicks. An inset outline on <body> was
|
||||||
|
tried first but overdrew content in tools whose root layout butts
|
||||||
|
right up to the viewport edge (browse split-pane, archive grid). */
|
||||||
|
body.is-elevated::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid var(--danger, #dc3545);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9200; /* above banner (9100) so the frame paints on top */
|
||||||
}
|
}
|
||||||
|
|
||||||
.elevation-banner {
|
.elevation-banner {
|
||||||
|
|
@ -950,63 +959,6 @@ body.is-elevated {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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. */
|
|
||||||
|
|
||||||
.zddc-stage-strip {
|
|
||||||
display: 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 {
|
|
||||||
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;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage--active {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zddc-stage--active:hover {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
/* shared/logo.css — paired with shared/logo.js. The wrapping anchor
|
||||||
inherits the logo's box and adds a subtle hover/focus affordance
|
inherits the logo's box and adds a subtle hover/focus affordance
|
||||||
so it reads as clickable without altering the logo's visual weight. */
|
so it reads as clickable without altering the logo's visual weight. */
|
||||||
|
|
@ -1559,7 +1511,7 @@ body.is-elevated {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 15:55:46 · df19a63-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-18 21:36:23 · cff840e-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -2715,211 +2667,6 @@ body.is-elevated {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/nav.js — lateral navigation strip across the project's
|
|
||||||
// cascade-declared stages. Mounted as a sibling of <header class="app-
|
|
||||||
// header"> on DOMContentLoaded, hydrated from the project root's
|
|
||||||
// directory listing.
|
|
||||||
//
|
|
||||||
// Stage discovery is cascade-driven (Phase 4c): fetch the project
|
|
||||||
// root's JSON listing, filter to entries with `declared: true`
|
|
||||||
// (server stamps these from the .zddc cascade's paths: tree), and
|
|
||||||
// render in canonical workflow order with display_name overrides
|
|
||||||
// honored. An operator who edits the project's .zddc paths: to add
|
|
||||||
// a new declared child sees it in the strip; one who removes a
|
|
||||||
// canonical entry sees the strip drop it.
|
|
||||||
//
|
|
||||||
// When the fetch fails (offline / no-server / file://), the strip
|
|
||||||
// falls back to the hardcoded four-stage list so existing
|
|
||||||
// deployments don't lose chrome. Hardcoded labels in this file are
|
|
||||||
// the LAST resort — the cascade is the source of truth in normal
|
|
||||||
// operation.
|
|
||||||
//
|
|
||||||
// Stage URLs follow the slash/no-slash convention: no slash opens
|
|
||||||
// the stage's default tool. Operators on non-standard layouts can
|
|
||||||
// override by setting window.zddc.nav.disabled = true before
|
|
||||||
// DOMContentLoaded.
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
|
||||||
if (window.zddc.nav) return; // already loaded
|
|
||||||
|
|
||||||
// Hardcoded fallback for offline / file:// / fetch-error contexts.
|
|
||||||
// Server-driven discovery (FETCH_STAGES below) is the normal path.
|
|
||||||
var FALLBACK_STAGES = [
|
|
||||||
{ name: 'archive', label: 'Archive' },
|
|
||||||
{ name: 'working', label: 'Working' },
|
|
||||||
{ name: 'staging', label: 'Staging' },
|
|
||||||
{ name: 'reviewing', label: 'Reviewing' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Canonical workflow order. Stages appearing in this list are
|
|
||||||
// rendered in this order; any extras the cascade declares are
|
|
||||||
// appended alphabetically.
|
|
||||||
var WORKFLOW_ORDER = ['archive', 'working', 'staging', 'reviewing'];
|
|
||||||
|
|
||||||
function projectSegment(pathname) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
var first = parts[0];
|
|
||||||
if (first.indexOf('.') !== -1) return null;
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentStage(pathname, stages) {
|
|
||||||
var parts = pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
var second = parts[1];
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
if (second.toLowerCase() === stages[i].name.toLowerCase()) {
|
|
||||||
return stages[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (second === 'archive.html') return 'archive';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRender() {
|
|
||||||
if (typeof location === 'undefined') return false;
|
|
||||||
if (location.protocol !== 'http:' && location.protocol !== 'https:') return false;
|
|
||||||
if (window.zddc.nav && window.zddc.nav.disabled) return false;
|
|
||||||
return projectSegment(location.pathname) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s) {
|
|
||||||
if (!s) return s;
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByWorkflow(stages) {
|
|
||||||
return stages.slice().sort(function (a, b) {
|
|
||||||
var ia = WORKFLOW_ORDER.indexOf(a.name.toLowerCase());
|
|
||||||
var ib = WORKFLOW_ORDER.indexOf(b.name.toLowerCase());
|
|
||||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
|
||||||
if (ia >= 0) return -1;
|
|
||||||
if (ib >= 0) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the project root listing and extract declared stage
|
|
||||||
// entries. Returns [] on any error so callers fall back to the
|
|
||||||
// hardcoded list. Each stage entry is {name, label} — label
|
|
||||||
// honors the cascade's display: override when present.
|
|
||||||
async function fetchStagesFor(project) {
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/' + encodeURIComponent(project) + '/', {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
});
|
|
||||||
if (!resp.ok) return [];
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!Array.isArray(data)) return [];
|
|
||||||
var stages = [];
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var e = data[i];
|
|
||||||
if (!e || !e.declared || !e.is_dir) continue;
|
|
||||||
var bare = (e.name || '').replace(/\/$/, '');
|
|
||||||
if (!bare) continue;
|
|
||||||
stages.push({
|
|
||||||
name: bare,
|
|
||||||
label: e.display_name || titleCase(bare),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return sortByWorkflow(stages);
|
|
||||||
} catch (_e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStrip(project, active, stages) {
|
|
||||||
var nav = document.createElement('nav');
|
|
||||||
nav.className = 'zddc-stage-strip';
|
|
||||||
nav.setAttribute('aria-label', 'Project stage');
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.className = 'zddc-stage-strip__project';
|
|
||||||
label.textContent = project;
|
|
||||||
nav.appendChild(label);
|
|
||||||
|
|
||||||
var sep0 = document.createElement('span');
|
|
||||||
sep0.className = 'zddc-stage-strip__divider';
|
|
||||||
sep0.setAttribute('aria-hidden', 'true');
|
|
||||||
sep0.textContent = '/';
|
|
||||||
nav.appendChild(sep0);
|
|
||||||
|
|
||||||
for (var i = 0; i < stages.length; i++) {
|
|
||||||
var s = stages[i];
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'zddc-stage';
|
|
||||||
a.href = '/' + encodeURIComponent(project) + '/' + s.name;
|
|
||||||
a.textContent = s.label;
|
|
||||||
if (s.name === active) {
|
|
||||||
a.classList.add('zddc-stage--active');
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
nav.appendChild(a);
|
|
||||||
|
|
||||||
if (i < stages.length - 1) {
|
|
||||||
var sep = document.createElement('span');
|
|
||||||
sep.className = 'zddc-stage-strip__sep';
|
|
||||||
sep.setAttribute('aria-hidden', 'true');
|
|
||||||
sep.textContent = '·';
|
|
||||||
nav.appendChild(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nav;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountWith(project, stages) {
|
|
||||||
var header = document.querySelector('.app-header');
|
|
||||||
if (!header) return;
|
|
||||||
if (header.previousElementSibling &&
|
|
||||||
header.previousElementSibling.classList &&
|
|
||||||
header.previousElementSibling.classList.contains('zddc-stage-strip')) {
|
|
||||||
return; // already mounted
|
|
||||||
}
|
|
||||||
var active = currentStage(location.pathname, stages);
|
|
||||||
var strip = buildStrip(project, active, stages);
|
|
||||||
header.parentNode.insertBefore(strip, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mount() {
|
|
||||||
if (!shouldRender()) return;
|
|
||||||
var project = projectSegment(location.pathname);
|
|
||||||
if (!project) return;
|
|
||||||
|
|
||||||
// Render the hardcoded fallback immediately so the strip
|
|
||||||
// appears with no flicker, then upgrade to cascade-resolved
|
|
||||||
// stages once the fetch completes.
|
|
||||||
mountWith(project, FALLBACK_STAGES);
|
|
||||||
|
|
||||||
var fetched = await fetchStagesFor(project);
|
|
||||||
if (fetched.length === 0) return; // fetch failed → keep fallback
|
|
||||||
|
|
||||||
// Replace the strip with the cascade-driven one. Remove the
|
|
||||||
// existing strip first so mountWith re-mounts cleanly.
|
|
||||||
var existing = document.querySelector('.zddc-stage-strip');
|
|
||||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
||||||
mountWith(project, fetched);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.zddc.nav = {
|
|
||||||
mount: mount,
|
|
||||||
_projectSegment: projectSegment,
|
|
||||||
_currentStage: currentStage,
|
|
||||||
_fallbackStages: FALLBACK_STAGES,
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
||||||
} else {
|
|
||||||
mount();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
// shared/logo.js — turn the inert <svg class="app-header__logo"> on
|
||||||
// every tool's header into a clickable link. The destination is the
|
// every tool's header into a clickable link. The destination is the
|
||||||
// nearest "home" the user can sensibly back out to:
|
// nearest "home" the user can sensibly back out to:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue