Round of UX fixes: tool strip removed, MDL routing, browse markdown layout, reviewing depth-2

Four user-reported items:

1. landing: remove the standalone-tool strip from the site picker.
   Per user, it was awkward — links pointing at zddc.varasys.io
   releases from inside a deployment is a layering confusion. The
   nav.tool-strip block in landing/template.html and its CSS are
   gone.

2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
   app for the virtual-MDL case where the on-disk folder doesn't
   exist yet. Previously fell through to 404 because the dispatcher
   only routed virtual mdl/ via the IsDir branch — the IsNotExist
   branch was missing the equivalent check. Now both shapes (with
   and without trailing slash) hit RecognizeTableRequest's default-
   MDL fallback and ServeTable serves the embedded tables.html.

3. browse: re-layout the markdown editor to mirror mdedit's layout.
   Was: sidebar on right with TOC top + front-matter bottom.
   Now: sidebar on LEFT with YAML front matter top + Outline bottom,
        content on RIGHT with an informational header (file title +
        save controls + status + source) above the Toast UI editor.
   New horizontal resizer between the front-matter and outline
   sections inside the sidebar (drag the row boundary; arrow keys
   step by 24 px). Browse test selectors updated.

4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
   user can preview files inside virtual reviewing/<tracking>/
   received/ and staged/ folders. IsReviewingPath now returns a
   sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
   depth-2 branch proxies the underlying real folder's listing,
   emitting folder entries with virtual reviewing/ URLs (so
   navigation stays in the aggregator) and file entries with
   canonical archive/ or staging/ URLs (so byte fetches resolve
   directly). ACL is enforced against the real path; depth-1
   received/ + staged/ URLs are now virtual too (was canonical),
   so the user smoothly descends into the depth-2 listing.

Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 12:30:34 -05:00
parent b1479c5104
commit d052e9fed3
9 changed files with 474 additions and 295 deletions

View file

@ -360,80 +360,43 @@ html, body {
.status-bar.is-info { color: var(--text); }
/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */
/* CSS-Grid shell. Two columns (editor | sidebar) and two rows (toolbar
| body). The grid gives every cell a definite size, which Toast UI
needs to compute its scroll regions correctly. A 4-px resizer sits
between the editor and sidebar; JS updates grid-template-columns on
drag. */
/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT
(front matter top + TOC bottom), content on the RIGHT (informational
header above the Toast UI editor). The grid gives every cell a
definite size, which Toast UI needs to compute its scroll regions
correctly. */
.md-shell {
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 260px; /* JS overrides on resize */
grid-template-areas:
"toolbar toolbar"
"editor sidebar";
grid-template-rows: 1fr;
grid-template-columns: 280px 1fr; /* JS overrides on resize */
grid-template-areas: "sidebar content";
height: 100%;
min-height: 0;
background: var(--bg);
overflow: hidden;
}
/* Toolbar spans both columns; subtle row above the editor. */
.md-shell__toolbar {
grid-area: toolbar;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.md-shell__dirty {
color: var(--text-muted);
font-size: 0.85rem;
min-width: 5.5rem;
}
.md-shell__status {
flex: 1;
text-align: right;
color: var(--text-muted);
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-shell__source {
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
margin-left: 0.5rem;
padding: 0.15rem 0.4rem;
border-radius: var(--radius);
background: var(--bg);
border: 1px solid var(--border);
}
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */
.md-shell__editor {
grid-area: editor;
min-width: 0;
/* Sidebar (col 1): two stacked sections Front matter (top, fixed
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */
.md-shell__sidebar {
grid-area: sidebar;
display: grid;
grid-template-rows: 180px 1fr; /* JS overrides on resize */
min-height: 0;
overflow: hidden;
/* Toast UI mounts a .toastui-editor-defaultUI element here; give
it a definite height via height:100% in the JS. */
border-right: 1px solid var(--border);
background: var(--bg);
position: relative;
}
/* Resizer sits on the grid border between editor (col 1) and sidebar
(col 2). Positioned absolutely over the boundary so it doesn't take
up a grid track itself. */
/* Vertical sidebar/content resizer. Sits absolutely on the column
boundary so it doesn't occupy a grid track. */
.md-shell__resizer {
grid-area: editor;
grid-area: sidebar;
align-self: stretch;
justify-self: end;
width: 6px;
margin-right: -3px; /* center on the column boundary */
margin-right: -3px;
cursor: col-resize;
background: transparent;
z-index: 2;
@ -446,17 +409,91 @@ html, body {
outline: none;
}
/* Sidebar (right column): grid of two stacked sections Outline
(1fr) takes the bulk of the height, Front matter (auto, capped) is
below. */
.md-shell__sidebar {
grid-area: sidebar;
/* Horizontal resizer between front-matter and TOC inside the sidebar.
Spans both rows by placement, then absolutely positioned to overlay
the grid-row boundary. */
.md-shell__fmresizer {
grid-column: 1;
grid-row: 1;
align-self: end;
justify-self: stretch;
height: 6px;
margin-bottom: -3px;
cursor: row-resize;
background: transparent;
z-index: 2;
transition: background 0.12s;
}
.md-shell__fmresizer:hover,
.md-shell__fmresizer.is-dragging,
.md-shell__fmresizer:focus-visible {
background: var(--primary);
outline: none;
}
/* Content (col 2): informational header above the Toast UI editor. */
.md-shell__content {
grid-area: content;
display: grid;
grid-template-rows: 1fr auto;
grid-template-rows: auto 1fr;
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--border);
}
/* Informational header above the editor: file name on the left, then
dirty marker, status, source hint, save button. Reads as a header
for the content panel file metadata at a glance. */
.md-shell__infohdr {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.md-shell__title {
flex: 1;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.md-shell__dirty {
color: var(--text-muted);
font-size: 0.85rem;
min-width: 5.5rem;
text-align: right;
}
.md-shell__status {
color: var(--text-muted);
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 14rem;
}
.md-shell__source {
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
padding: 0.15rem 0.4rem;
border-radius: var(--radius);
background: var(--bg);
border: 1px solid var(--border);
}
/* Editor host: a single grid cell with overflow:hidden so Toast UI's
internal scrollers handle the content. */
.md-shell__editor {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.md-side {
@ -465,10 +502,8 @@ html, body {
min-height: 0;
overflow: hidden;
}
.md-side--fm {
.md-side--toc {
border-top: 1px solid var(--border);
/* Front matter doesn't dominate — cap it so the outline keeps room. */
max-height: 40%;
}
.md-side__header {
padding: 0.35rem 0.75rem;

View file

@ -28,9 +28,10 @@
if (!window.app || !window.app.modules) return;
var TOC_MIN_WIDTH = 180;
var TOC_MAX_WIDTH = 480;
var TOC_DEFAULT_WIDTH = 260;
var SIDEBAR_MIN_WIDTH = 180;
var SIDEBAR_MAX_WIDTH = 480;
var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
@ -38,7 +39,8 @@
}
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastTocWidth = TOC_DEFAULT_WIDTH; // remember across mounts
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT;
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
@ -302,20 +304,78 @@
}
// Wipe the container and install a single shell child. The
// shell is a CSS Grid with two rows (toolbar | body) and two
// columns (editor | sidebar). Setting these on a dedicated
// child — rather than touching previewBody's class — keeps
// the outer flex layout intact (previewBody itself is the
// flex item that fills the preview pane).
// shell mirrors mdedit's layout: sidebar on the LEFT (front
// matter top, TOC bottom), content on the RIGHT (informational
// header above the Toast UI editor). CSS Grid keeps every
// cell sized definitely so Toast UI's scroll regions resolve
// correctly.
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'md-shell';
shell.style.gridTemplateColumns = '1fr ' + lastTocWidth + 'px';
shell.style.gridTemplateColumns = lastSidebarWidth + 'px 1fr';
container.appendChild(shell);
// Toolbar (row 1, spans both columns).
var toolbar = document.createElement('div');
toolbar.className = 'md-shell__toolbar';
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ──────────────
var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
shell.appendChild(sidebar);
var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header';
fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
// Horizontal resizer between front-matter and TOC.
var fmResizer = document.createElement('div');
fmResizer.className = 'md-shell__fmresizer';
fmResizer.setAttribute('role', 'separator');
fmResizer.setAttribute('aria-orientation', 'horizontal');
fmResizer.setAttribute('aria-label', 'Resize front-matter pane');
fmResizer.tabIndex = 0;
sidebar.appendChild(fmResizer);
var tocSection = document.createElement('section');
tocSection.className = 'md-side md-side--toc';
var tocHeader = document.createElement('div');
tocHeader.className = 'md-side__header';
tocHeader.textContent = 'Outline';
var tocBody = document.createElement('div');
tocBody.className = 'md-side__body md-toc__body';
tocSection.appendChild(tocHeader);
tocSection.appendChild(tocBody);
sidebar.appendChild(tocSection);
// Vertical resizer between sidebar and content.
var resizer = document.createElement('div');
resizer.className = 'md-shell__resizer';
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');
resizer.setAttribute('aria-label', 'Resize sidebar');
resizer.tabIndex = 0;
shell.appendChild(resizer);
// ── Content (col 2): informational header + editor ──────────────────
var content = document.createElement('div');
content.className = 'md-shell__content';
shell.appendChild(content);
// Informational header above the editor: file name + save +
// dirty indicator + status + source hint. Renamed from
// "toolbar" to read as a header, since it titles the content.
var infohdr = document.createElement('div');
infohdr.className = 'md-shell__infohdr';
var titleEl = document.createElement('span');
titleEl.className = 'md-shell__title';
titleEl.textContent = node.name;
titleEl.title = node.name;
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
@ -339,52 +399,17 @@
sourceEl.textContent = 'server';
}
toolbar.appendChild(saveBtn);
toolbar.appendChild(dirtyEl);
toolbar.appendChild(statusEl);
toolbar.appendChild(sourceEl);
shell.appendChild(toolbar);
infohdr.appendChild(titleEl);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
infohdr.appendChild(saveBtn);
content.appendChild(infohdr);
// Editor host (row 2, col 1).
// Editor host.
var editorHost = document.createElement('div');
editorHost.className = 'md-shell__editor';
shell.appendChild(editorHost);
// Resizer between editor and sidebar (row 2, between cols).
var resizer = document.createElement('div');
resizer.className = 'md-shell__resizer';
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');
resizer.setAttribute('aria-label', 'Resize outline pane');
resizer.tabIndex = 0;
shell.appendChild(resizer);
// Sidebar (row 2, col 2). Its own grid: outline (1fr) + front-matter (auto).
var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar';
shell.appendChild(sidebar);
var tocSection = document.createElement('section');
tocSection.className = 'md-side md-side--toc';
var tocHeader = document.createElement('div');
tocHeader.className = 'md-side__header';
tocHeader.textContent = 'Outline';
var tocBody = document.createElement('div');
tocBody.className = 'md-side__body md-toc__body';
tocSection.appendChild(tocHeader);
tocSection.appendChild(tocBody);
sidebar.appendChild(tocSection);
var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm';
var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header';
fmHeader.textContent = 'Front matter';
var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body';
fmSection.appendChild(fmHeader);
fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection);
content.appendChild(editorHost);
// Construct the editor. height: 100% works because editorHost
// is a grid cell with a definite size.
@ -424,10 +449,9 @@
renderToc(tocBody, text, editor);
renderFrontMatter(fmBody, text);
// ── Resizer ────────────────────────────────────────────────────────
// Drag the resizer to grow/shrink the sidebar. Updates the
// container's grid-template-columns so the editor + sidebar
// both reflow cleanly.
// ── Sidebar/content resizer ─────────────────────────────────────────
// Sidebar is on the LEFT now. Dragging right grows the
// sidebar; left shrinks it.
(function () {
var dragging = false;
var startX = 0;
@ -435,12 +459,10 @@
function onMove(e) {
if (!dragging) return;
var dx = e.clientX - startX;
// Dragging right shrinks the sidebar; left grows it.
// (The sidebar is on the right; user expectation matches.)
var w = startW - dx;
w = Math.max(TOC_MIN_WIDTH, Math.min(TOC_MAX_WIDTH, w));
lastTocWidth = w;
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
var w = startW + dx;
w = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, w));
lastSidebarWidth = w;
shell.style.gridTemplateColumns = w + 'px 1fr';
e.preventDefault();
}
function onUp() {
@ -458,15 +480,58 @@
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
// Keyboard: ← / → adjust by 24px.
resizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
var step = e.key === 'ArrowLeft' ? 24 : -24;
var w = Math.max(TOC_MIN_WIDTH,
Math.min(TOC_MAX_WIDTH, lastTocWidth + step));
lastTocWidth = w;
shell.style.gridTemplateColumns = '1fr ' + w + 'px';
var step = e.key === 'ArrowLeft' ? -24 : 24;
var w = Math.max(SIDEBAR_MIN_WIDTH,
Math.min(SIDEBAR_MAX_WIDTH, lastSidebarWidth + step));
lastSidebarWidth = w;
shell.style.gridTemplateColumns = w + 'px 1fr';
});
})();
// ── Front-matter / TOC vertical resizer ─────────────────────────────
(function () {
var FM_MIN = 60;
var dragging = false;
var startY = 0;
var startH = 0;
function maxFmHeight() {
var sidebarRect = sidebar.getBoundingClientRect();
// Leave at least 120 px for the TOC body + headers.
return Math.max(FM_MIN, sidebarRect.height - 160);
}
function onMove(e) {
if (!dragging) return;
var dy = e.clientY - startY;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr';
e.preventDefault();
}
function onUp() {
dragging = false;
fmResizer.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
fmResizer.addEventListener('mousedown', function (e) {
dragging = true;
fmResizer.classList.add('is-dragging');
startY = e.clientY;
startH = fmSection.getBoundingClientRect().height;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
fmResizer.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
var step = e.key === 'ArrowUp' ? -24 : 24;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr';
});
})();

View file

@ -13,51 +13,6 @@ body {
padding: 0 16px 64px;
}
/* Standalone-tool strip. Sits above the hero on the picker view. Each
link is a small card with the tool name (display serif) and a short
one-line hint (sans, muted). Hover lifts the card slightly to signal
it's clickable; the strip wraps onto multiple lines on narrow widths
rather than scrolling horizontally. */
.tool-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 24px;
padding: 0 4px;
}
.tool-strip__link {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 0.5rem 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-decoration: none;
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
min-width: 110px;
}
.tool-strip__link:hover {
border-color: var(--primary);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
text-decoration: none;
}
.tool-strip__name {
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
line-height: 1.15;
color: var(--text);
}
.tool-strip__hint {
font-size: 0.78rem;
color: var(--text-muted);
line-height: 1.2;
}
/* Welcome / hero */
.landing-hero {
margin: 0 0 24px;

View file

@ -34,48 +34,6 @@
<main id="landingMain" class="landing-main">
<!-- Picker mode (deployment root /). Project picker + groups. -->
<div id="pickerView">
<!-- Standalone-tool strip. Each link opens the latest stable
single-file build of one ZDDC tool from the canonical
release host (zddc.varasys.io/releases/). Useful for
"try this tool" / offline use without a project context. -->
<nav class="tool-strip" aria-label="ZDDC tools">
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/archive_stable.html"
title="Browse a project archive — filter by party, status, revision">
<span class="tool-strip__name">Archive</span>
<span class="tool-strip__hint">Browse &amp; filter</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/transmittal_stable.html"
title="Compose, validate, and publish transmittals">
<span class="tool-strip__name">Transmittal</span>
<span class="tool-strip__hint">Issue &amp; receive</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/classifier_stable.html"
title="Rename incoming files into ZDDC tracking numbers">
<span class="tool-strip__name">Classifier</span>
<span class="tool-strip__hint">Rename incoming</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/mdedit_stable.html"
title="Markdown editor with multi-file FS-Access workflow">
<span class="tool-strip__name">Markdown</span>
<span class="tool-strip__hint">Edit prose</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/browse_stable.html"
title="Unified file tree + per-file-type preview">
<span class="tool-strip__name">Browse</span>
<span class="tool-strip__hint">Files &amp; preview</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/form_stable.html"
title="Schema-driven form renderer for *.form.yaml files">
<span class="tool-strip__name">Form</span>
<span class="tool-strip__hint">Structured input</span>
</a>
<a class="tool-strip__link" href="https://zddc.varasys.io/releases/tables_stable.html"
title="Aggregate a directory of YAML rows into a sortable table">
<span class="tool-strip__name">Tables</span>
<span class="tool-strip__hint">YAML rollup</span>
</a>
</nav>
<!-- Welcome / hero -->
<section class="landing-hero">
<h1>Welcome to the ZDDC Archive</h1>

View file

@ -90,11 +90,12 @@ test.describe('Browse', () => {
await page.waitForSelector('#treeBody .tree-row[data-isdir="false"]', { timeout: 10000 });
await page.locator('#treeBody .tree-row[data-isdir="false"]').first().click();
// Markdown plugin DOM mounts: shell, toolbar, editor host, sidebar.
// Markdown plugin DOM mounts: shell, sidebar (front matter +
// TOC), content (info header + editor).
await expect(page.locator('.md-shell')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.md-shell__toolbar')).toBeVisible();
await expect(page.locator('.md-shell__editor')).toBeVisible();
await expect(page.locator('.md-shell__sidebar')).toBeVisible();
await expect(page.locator('.md-shell__infohdr')).toBeVisible();
await expect(page.locator('.md-shell__editor')).toBeVisible();
// Outline lists the three headings.
await page.waitForSelector('.md-toc__list .md-toc__item', { timeout: 10000 });

View file

@ -853,7 +853,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Depth-2 no-slash (reviewing) falls through to the canonical-
// folder block below where DefaultAppAt routes to mdedit.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if proj, tracking, ok := handler.IsReviewingPath(urlPath); ok {
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
if !strings.HasSuffix(urlPath, "/") {
if tracking != "" {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
@ -866,13 +866,34 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeReviewing(cfg, w, r, proj, tracking)
handler.ServeReviewing(cfg, w, r, proj, tracking, sidePath)
return
}
// HTML trailing-slash falls through to canonical-folder
// block → ServeDirectory → embedded browse.html.
}
}
// Default-MDL virtual directory at archive/<party>/mdl[/].
// The rows-dir doesn't have to exist on disk —
// RecognizeTableRequest's default-MDL fallback handles a
// fully-missing path so a fresh party with no entries yet
// still lands on a usable table view (rather than 404).
// Both slash and no-slash forms serve the tables app
// directly; the slash form is the canonical URL the MDL
// card on the project landing page links to.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
base := strings.TrimSuffix(urlPath, "/")
synth := base + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeTable(cfg, tr, w, r)
return
}
}
// Canonical project-root folder fallback. <project>/{archive,
// working,staging,reviewing}[/] should land on a usable view
// (default tool or empty listing) rather than 404, so the

View file

@ -18,30 +18,48 @@ import (
)
// IsReviewingPath classifies a URL as a reviewing-aggregator path and
// extracts (project, tracking). The aggregator is a virtual view at:
// extracts (project, tracking, sidePath). The aggregator is a virtual
// view at:
//
// <project>/reviewing/ → depth 0: list pending submittals
// <project>/reviewing/<tracking>/ → depth 1: list received/ + staged/
// <project>/reviewing/ → depth 0: pending submittals
// <project>/reviewing/<tracking>/ → depth 1: received/ + staged/
// <project>/reviewing/<tracking>/<side>/[...] → depth ≥ 2: real folder
// contents (received or
// staged), proxied from
// the canonical archive
// or staging path so the
// user can preview files
// in the browse pane
// without leaving the
// reviewing view.
//
// Anything deeper than depth 1 returns ok=false; the depth-1 listing
// emits canonical URLs (under archive/ and staging/) so navigation past
// that point goes through the regular file-tree handlers, not back into
// the virtual reviewing/ subtree.
// sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's
// "received[/rest...]" or "staged[/rest...]" — the slash-separated
// remainder after the tracking segment.
//
// Trailing slash on either depth is required and tolerated. Match on
// "reviewing" is case-insensitive.
func IsReviewingPath(urlPath string) (project, tracking string, ok bool) {
// Match on "reviewing" is case-insensitive.
func IsReviewingPath(urlPath string) (project, tracking, sidePath string, ok bool) {
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
return "", "", false
return "", "", "", false
}
switch len(parts) {
case 2:
return parts[0], "", true
return parts[0], "", "", true
case 3:
return parts[0], parts[2], true
return parts[0], parts[2], "", true
default:
return "", "", false
// parts[3] is the side; remainder joins back as the sub-path
// within the real folder.
side := strings.ToLower(parts[3])
if side != "received" && side != "staged" {
return "", "", "", false
}
rest := strings.Join(parts[4:], "/")
if rest == "" {
return parts[0], parts[2], side, true
}
return parts[0], parts[2], side + "/" + rest, true
}
}
@ -213,13 +231,27 @@ func computePending(ctx context.Context, decider policy.Decider,
return result, nil
}
// ServeReviewing emits the aggregator JSON listing for either depth 0
// (project's full pending list) or depth 1 (one submittal's
// received/ + staged/ pair). The HTML branch is handled separately by
// the apps subsystem (mdedit served at the URL); only requests that
// accept JSON reach here.
// ServeReviewing emits the aggregator JSON listing for any depth under
// <project>/reviewing/. The HTML branch is handled separately by the
// apps subsystem (mdedit served at the URL); only requests that accept
// JSON reach here.
//
// Depths:
//
// 0 (tracking="") → list pending submittals as virtual
// <tracking>/ folders.
// 1 (tracking, side="") → list received/ + staged/ virtual folders.
// ≥2 (tracking, sidePath) → proxy the listing of the real folder
// under archive/<party>/received/<folder>/...
// or staging/<folder>/... so the user can
// preview files without leaving the
// reviewing view. Folder entries keep
// virtual reviewing/ URLs (navigation
// stays in the aggregator). File entries
// use canonical URLs so byte fetches
// resolve directly against the real path.
func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
project, tracking string) {
project, tracking, sidePath string) {
pending, err := computePending(r.Context(), DeciderFromContext(r),
cfg.Root, project, EmailFromContext(r))
@ -229,11 +261,9 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
}
var entries []listing.FileInfo
switch tracking {
case "":
switch {
case tracking == "":
// Depth 0: list pending submittals as virtual <tracking>/ folders.
// The URLs stay under reviewing/ so the user can drill into a
// per-submittal view.
urlPrefix := "/" + project + "/reviewing/"
for _, s := range pending {
entries = append(entries, listing.FileInfo{
@ -245,10 +275,7 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
})
}
default:
// Depth 1: find the matching pending entry; emit received/ +
// staged/ pointing at canonical archive/staging URLs. Clients
// using the polyfill follow these URLs out of the virtual
// subtree into the real file paths underneath.
// Depth ≥1: find the pending entry for this tracking number.
var match *pendingSubmittal
for i := range pending {
if pending[i].tracking == tracking {
@ -260,21 +287,130 @@ func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
http.Error(w, "Not Found", http.StatusNotFound)
return
}
entries = append(entries, listing.FileInfo{
Name: "received/",
URL: match.receivedURL,
ModTime: match.lastModified,
IsDir: true,
Virtual: true,
})
if match.stagedURL != "" {
if sidePath == "" {
// Depth 1: emit received/ + staged/ virtual folder pointers.
// URLs stay under reviewing/ so navigation into them remains
// in the aggregator (handled by the depth ≥2 branch).
urlPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/"
entries = append(entries, listing.FileInfo{
Name: "staged/",
URL: match.stagedURL,
Name: "received/",
URL: urlPrefix + "received/",
ModTime: match.lastModified,
IsDir: true,
Virtual: true,
})
if match.stagedURL != "" {
entries = append(entries, listing.FileInfo{
Name: "staged/",
URL: urlPrefix + "staged/",
ModTime: match.lastModified,
IsDir: true,
Virtual: true,
})
}
} else {
// Depth ≥2: proxy the real folder's listing. sidePath is
// "received[/rest]" or "staged[/rest]" — split off the
// leading side, append remainder to the canonical base.
side := sidePath
rest := ""
if i := strings.IndexByte(sidePath, '/'); i >= 0 {
side, rest = sidePath[:i], sidePath[i+1:]
}
var realURL string
switch side {
case "received":
realURL = match.receivedURL
case "staged":
if match.stagedURL == "" {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
realURL = match.stagedURL
default:
http.Error(w, "Not Found", http.StatusNotFound)
return
}
if rest != "" {
realURL = strings.TrimSuffix(realURL, "/") + "/" + rest + "/"
}
// Translate the real URL back to a filesystem path so we
// can list it. The URL still encodes percent-escapes;
// PathUnescape them before joining.
realRel := strings.TrimPrefix(realURL, "/")
realRel = strings.TrimSuffix(realRel, "/")
realRelDecoded, decodeErr := url.PathUnescape(realRel)
if decodeErr != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
realAbs := filepath.Join(cfg.Root, filepath.FromSlash(realRelDecoded))
if !strings.HasPrefix(realAbs, cfg.Root+string(filepath.Separator)) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// ACL on the underlying real path; do not proxy what the
// caller can't read directly.
chain, err := zddc.EffectivePolicy(cfg.Root, realAbs)
if err == nil {
if allowed, _ := policy.AllowFromChain(r.Context(),
DeciderFromContext(r), chain,
EmailFromContext(r), realURL); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
diskEntries, err := os.ReadDir(realAbs)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Build the virtual URL prefix (for folder entries) and
// the canonical URL prefix (for file entries).
virtualPrefix := "/" + project + "/reviewing/" +
url.PathEscape(tracking) + "/" + side + "/"
if rest != "" {
virtualPrefix += rest + "/"
}
canonicalPrefix := realURL // already ends with "/"
for _, e := range diskEntries {
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
fi := listing.FileInfo{
Name: name,
ModTime: info.ModTime(),
}
if e.IsDir() {
fi.Name += "/"
fi.IsDir = true
fi.URL = virtualPrefix + url.PathEscape(name) + "/"
fi.Virtual = true
} else {
fi.Size = info.Size()
// File URL points at the canonical real path so
// fetches (preview, download) hit the right bytes
// directly — no proxying through the aggregator.
fi.URL = canonicalPrefix + url.PathEscape(name)
}
entries = append(entries, fi)
}
sort.Slice(entries, func(i, j int) bool {
// Folders first, then files; both alphabetical.
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir
}
return entries[i].Name < entries[j].Name
})
}
}

View file

@ -18,26 +18,32 @@ func TestIsReviewingPath(t *testing.T) {
wantOK bool
wantProj string
wantTracking string
wantSide string
}{
{"/Project/reviewing/", true, "Project", ""},
{"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001"},
{"/Project/reviewing/", true, "Project", "", ""},
{"/Project/reviewing/123-EM-SUB-0001/", true, "Project", "123-EM-SUB-0001", ""},
// Case-insensitive on the literal "reviewing" segment.
{"/Project/Reviewing/", true, "Project", ""},
{"/Project/REVIEWING/x/", true, "Project", "x"},
{"/Project/Reviewing/", true, "Project", "", ""},
{"/Project/REVIEWING/x/", true, "Project", "x", ""},
// No trailing slash: still classified (caller decides redirect).
{"/Project/reviewing", true, "Project", ""},
{"/Project/reviewing/123/", true, "Project", "123"},
{"/Project/reviewing", true, "Project", "", ""},
{"/Project/reviewing/123/", true, "Project", "123", ""},
// Depth 2+: side present.
{"/Project/reviewing/123/received/", true, "Project", "123", "received"},
{"/Project/reviewing/123/staged/", true, "Project", "123", "staged"},
{"/Project/reviewing/123/received/sub/", true, "Project", "123", "received/sub"},
// Unknown side at depth 2 is rejected.
{"/Project/reviewing/123/issued/", false, "", "", ""},
// Non-canonical / wrong shape.
{"/Project/", false, "", ""},
{"/", false, "", ""},
{"/Project/working/", false, "", ""},
{"/Project/reviewing/x/y/", false, "", ""}, // depth >3 not supported
{"/Project/", false, "", "", ""},
{"/", false, "", "", ""},
{"/Project/working/", false, "", "", ""},
}
for _, tc := range cases {
gotProj, gotTracking, gotOK := IsReviewingPath(tc.path)
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking {
t.Errorf("IsReviewingPath(%q) = (%q,%q,%v), want (%q,%q,%v)",
tc.path, gotProj, gotTracking, gotOK, tc.wantProj, tc.wantTracking, tc.wantOK)
gotProj, gotTracking, gotSide, gotOK := IsReviewingPath(tc.path)
if gotOK != tc.wantOK || gotProj != tc.wantProj || gotTracking != tc.wantTracking || gotSide != tc.wantSide {
t.Errorf("IsReviewingPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)",
tc.path, gotProj, gotTracking, gotSide, gotOK, tc.wantProj, tc.wantTracking, tc.wantSide, tc.wantOK)
}
}
}
@ -85,7 +91,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
ServeReviewing(cfg, rec, req, "Project", "")
ServeReviewing(cfg, rec, req, "Project", "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
@ -122,7 +128,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007")
ServeReviewing(cfg, rec, req, "Project", "002-AB-SUB-0007", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
@ -138,15 +144,17 @@ func TestServeReviewing(t *testing.T) {
if got[0].Name != "received/" {
t.Errorf("entries[0].Name=%q, want %q", got[0].Name, "received/")
}
// Canonical URL — outside reviewing/ subtree.
if want := "/Project/archive/Beta/received/"; !startsWith(got[0].URL, want) {
t.Errorf("received URL=%q, want prefix %q", got[0].URL, want)
// Virtual URL — stays under reviewing/ so depth-2 navigation
// returns to the aggregator (which lists the real folder's
// contents with canonical file URLs).
if want := "/Project/reviewing/002-AB-SUB-0007/received/"; got[0].URL != want {
t.Errorf("received URL=%q, want %q", got[0].URL, want)
}
if got[1].Name != "staged/" {
t.Errorf("entries[1].Name=%q, want %q", got[1].Name, "staged/")
}
if want := "/Project/staging/"; !startsWith(got[1].URL, want) {
t.Errorf("staged URL=%q, want prefix %q", got[1].URL, want)
if want := "/Project/reviewing/002-AB-SUB-0007/staged/"; got[1].URL != want {
t.Errorf("staged URL=%q, want %q", got[1].URL, want)
}
})
@ -155,7 +163,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001")
ServeReviewing(cfg, rec, req, "Project", "001-AB-SUB-0001", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
@ -174,7 +182,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999")
ServeReviewing(cfg, rec, req, "Project", "999-ZZ-SUB-9999", "")
if rec.Code != http.StatusNotFound {
t.Errorf("status=%d, want 404", rec.Code)
}
@ -193,7 +201,7 @@ func TestServeReviewing(t *testing.T) {
req.Header.Set("Accept", "application/json")
req = req.WithContext(WithEmail(req.Context(), "alice@example.com"))
rec := httptest.NewRecorder()
ServeReviewing(bareCfg, rec, req, "Fresh", "")
ServeReviewing(bareCfg, rec, req, "Fresh", "", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, body=%s", rec.Code, rec.Body.String())
}

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</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-11 17:07:33 · c87fb7f-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 17:29:36 · b1479c5-dirty</span></span>
</div>
</div>
<div class="header-right">