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/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -47,7 +46,6 @@ concat_files \
|
|||
"../shared/zip-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/init.js" \
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ concat_files \
|
|||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"../shared/vendor/toastui-editor.min.css" \
|
||||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
|
|
@ -51,7 +50,6 @@ concat_files \
|
|||
"../shared/zip-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.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/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -45,7 +44,6 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/app.js" \
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/form.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -30,7 +29,6 @@ concat_files \
|
|||
concat_files \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ concat_files \
|
|||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/landing.css" \
|
||||
> "$css_temp"
|
||||
|
|
@ -32,7 +31,6 @@ concat_files \
|
|||
"../shared/zddc-filter.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.js" \
|
||||
"../shared/elevation.js" \
|
||||
|
|
|
|||
|
|
@ -48,16 +48,25 @@
|
|||
|
||||
/* 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 page (body) — peripheral-
|
||||
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. */
|
||||
body.is-elevated {
|
||||
outline: 3px solid var(--danger, #dc3545);
|
||||
outline-offset: -3px;
|
||||
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 <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 {
|
||||
|
|
|
|||
|
|
@ -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/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"css/table.css" \
|
||||
|
|
@ -40,7 +39,6 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/help.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/toast.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/layout.css" \
|
||||
|
|
@ -55,7 +54,6 @@ concat_files \
|
|||
"../shared/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.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
|
||||
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.
|
||||
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. */
|
||||
body.is-elevated {
|
||||
outline: 3px solid var(--danger, #dc3545);
|
||||
outline-offset: -3px;
|
||||
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 <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 {
|
||||
|
|
@ -950,63 +959,6 @@ body.is-elevated {
|
|||
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
|
||||
inherits the logo's box and adds a subtle hover/focus affordance
|
||||
so it reads as clickable without altering the logo's visual weight. */
|
||||
|
|
@ -1559,7 +1511,7 @@ body.is-elevated {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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 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
|
||||
// every tool's header into a clickable link. The destination is the
|
||||
// nearest "home" the user can sensibly back out to:
|
||||
|
|
|
|||
Loading…
Reference in a new issue