Searching no longer force-expands every folder. computeVisible now returns an
"open" set holding just the connector folders on the path down to each match;
those open to expose the hit, while off-path branches and terminal nodes keep
their real collapse state (and honest ▶/▼ arrows). Reshaping the tree is the
user's call — the root's expand-all is one click away.
Test: a deep file hit opens its branch and leaves the sibling collapsed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Toggling Show Unassigned/Assigned/Excluded/Empty no longer force-expands the
whole tree. Auto-expand is now reserved for the NAME search (where revealing a
match means expanding to it); the Show toggles only hide/show, leaving your
expand/collapse state untouched. Also preserve scrollTop across re-render so a
toggle doesn't jump the view to the top.
- Add a "Reset" button (danger-styled, beside Export/Import) that discards every
classification — tracking + transmittal trees, assignments, excludes, title
overrides — and returns to just the raw scanned input. Your files are never
touched. Destructive + irreversible, so it confirms with an "Export first"
warning and no-ops (info toast) when there's nothing to reset.
Tests: Show toggle preserves collapse vs. name-search auto-expand (classify 42).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a "Show Empty" checkbox (classify mode) — when off, folders whose whole
subtree contains no files are hidden, decluttering messy scans.
- Move the Show Unassigned/Assigned/Excluded/Empty filters out of the cramped
pane header into a dedicated "Show …" toolbar row beneath it (wraps cleanly).
- Drop the "X folders selected" text from the folder-tree header (selection
still works; updateSelectedCount guards the now-absent element).
Test: Show Empty off hides file-less folders (classify.spec.js -> 41).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an autofilter input above the source tree and above each target tree.
Typing substring-matches (ANDing space-separated terms) against the full file
path/name (and folder/node names) and reveals every match with the folder
hierarchy leading to it — non-matching branches collapse out, matching branches
auto-expand. So you can type "master deliverables list" and jump straight to it.
- Source tree (tree.js): one-pass visible-set over path+name; composes with the
Show Unassigned/Assigned/Excluded toggles; auto-expands to reveal hits.
- Target trees (target-tree.js): tracking + transmittal nodes are filter-aware
(match node names + each placed file's original/derived name); one shared
query mirrored across both tab inputs.
Tests: source-tree path reveal + tracking-tree node filter (classify.spec.js -> 36).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the ID-based dataset export/import (which required an external editor
to build a nested tree and keep node ids consistent) with a flat, AI-friendly
list: one record per input file carrying its full ZDDC filename — and an
optional transmittal {party, slot, date, type, seq, status, title}.
- Export: one {source, originalName, filename, excluded, transmittal?} record
per source file (filename = the derived ZDDC name, "" if unassigned).
- Import: parses each filename and rebuilds the tracking tree (parseFolderLevels
+ addTrackingPath, sharing ancestors); excluded files are marked; transmittals
are reconstructed with party/bin dedup. No node ids for the editor to manage.
New classify helpers: transmittalRecord (export), findOrAddParty /
findOrAddTransmittalBin (import dedup). serialize/load stay for workspace
persistence. Test rewritten for the filename round-trip (classify.spec.js -> 34).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Export / Import buttons to the Classify & Copy header so the full dataset
(tracking + transmittal trees, per-file assignments, output name) round-trips
through a JSON file — export it, edit externally (e.g. with an AI), re-import.
- Export downloads a self-documenting JSON (canonical classify.serialize() state
+ an informational sourceFiles inventory + a _format note). Lossless: empty
tree branches and unassigned state survive.
- Import validates, confirms before replacing a non-empty current dataset, and
loads via classify.load() (ignores the wrapper/_format/sourceFiles keys).
Test: serialize → JSON → load preserves trees (incl. an empty branch) +
assignments (classify.spec.js -> 34 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Classify & Copy interaction pass (replaces the single "Hide Assigned" toggle):
- Source-tree filters: three "Show Unassigned / Show Assigned / Show Excluded"
checkboxes (classify mode only) with live per-tab counts; "Hide Compliant" is
now rename-mode only. Folders with nothing visible collapse out.
- Target tree: ctrl/cmd-click a toggle to expand/collapse the whole subtree.
- Tracking drop-to-any-level: dropping on a node that isn't already a complete
leaf prompts for the remaining levels (e.g. "0001_0 (IFU)"), which are parsed
and nested under the drop target. Dropping on a finished leaf assigns directly.
- Placed-file rows: click to preview; the derived filename is now an inline
input — edit it (full "TRACKING_REV (STATUS) - Title.ext") and the item is
re-filed onto the parsed tracking path (created if needed) + title override.
New classify helpers: trackingNodeComplete, trackingPathLabel. tree.setShowFilters
replaces setHideAssigned. Tests updated/added (classify.spec.js -> 33 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the Classify & Copy add-folder work:
- Add-folder now parses each (brace-expanded) name into the nested tracking
levels it represents — split on "-", then the FINAL "_" splits the leaf
revision. "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU); a braced pattern nests
every expansion and shares common ancestors. New classify.parseFolderLevels
+ addTrackingPath (ensure-path with name reuse).
- Node add/edit/delete controls moved back to the RIGHT of the level name and
revealed on hover (was left + always-visible).
Tests: parseFolderLevels cases + a nested-chain/shared-ancestor test; updated
the "+ Root folder" test for the new nesting (classify.spec.js -> 31 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Classify & Copy polish — in either target tab the goal is to assign or exclude
every left-pane file until nothing remains:
- Hide Assigned checkbox (classify mode, in the folder-tree pane header):
collapses the source tree to only what's left on the ACTIVE axis — hides
files already assigned in the current tab (or excluded) and any folder whose
scanned subtree is thereby empty. Re-renders on tab switch; target-tree
exposes activeAxis().
- Node add/edit/delete controls moved to the LEFT of the level name and made
always-visible (was right-aligned + hover-only), so building/pruning the
tracking and transmittal trees is one click.
- Brace expansion in the add-folder box: "BMB-187023-{PM,EL,EM}-MOM-
{0001-0002,0005}_A (IFR)" creates all 9 folders — {a,b} alternation +
{N-M} zero-padded numeric ranges, cartesian product across groups; a
multi-create is confirmed first. New classify.expandFolderPattern().
Tests: expandFolderPattern unit cases + a Hide-Assigned DOM test
(classify.spec.js → 29 passed; classifier.spec.js → 4 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Welcome: drop the 'absorbed into Browse' notice; bigger, inviting intro with
a two-method tutorial (Classify & copy — recommended/non-destructive; Rename
in place — edits files) and a OneDrive 'keep on device' tip.
- Resumable scan: the snapshot now records per-folder scan state, the workspace
record is created up front, and the partial snapshot is persisted every 5s
during the (slow) scan. scanner.resumeScan() resolves handles for only the
still-pending folders and drains them — so an interrupted scan picks up where
it left off instead of starting over.
- Reconnect on restore: opening a workspace no longer assumes the source is
connected; a header 'Connect directory' button (and a prompt) re-grants the
persisted handle in one click or lets you re-pick it. Until connected you can
still edit the data model; connecting also resumes any pending scan.
- Tests: resume-scan via mock root handle (31 classify/classifier green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Identifying a file is half the workflow — you preview it to see what it is,
then assign its tracking number by drag. Preview was only wired into the old
Rename grid; in Classify & Copy a source file now previews on single-click
(drag still assigns, right-click excludes). preview.previewFile() resolves a
snapshot file's handle from the workspace root (one-click read re-grant) before
opening, so it works for resumed workspaces too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A folder with files but no subfolders got no expand toggle, so in Classify &
Copy mode its files (the drag source) could never be revealed — and leaf
folders full of files are exactly where the work is. Make a folder expandable
when it has files in classify mode; expanding lists the draggable file rows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The classifier re-scanned the source on every session; on cloud-backed mounts
(OneDrive/Samba) that's minutes of per-op latency. Workspaces fix it: scan a
folder ONCE, snapshot the completed tree, and resume instantly — all
classification runs on the data model; the filesystem is only touched at copy.
- persist.js v2: multi-workspace IndexedDB (tiny 'index' store for the welcome
list + 'data' store holding the source handle, tree snapshot, and map). DB v2.
- scanner.js: snapshotTree()/loadSnapshot() (compact, handle-less, marked done,
totals recomputed) + lazy resolveFileHandle/resolveDirHandle from the root.
- workspace.js: welcome manager (new/open/rename/delete), debounced autosave of
the active workspace, 'Refresh from disk' (re-scan → re-snapshot, path-keyed
map carries over). New workspace = the one slow full scan; reopen = instant.
- copy.js: resolves snapshot files' handles from the workspace root with a
one-click read permission re-grant; missing-on-disk files surface as errors.
- app.js: enterAppShell() shared by rename/workspace flows; exposes setMode;
classify.js decoupled from persistence.
- template/css: welcome workspace list + header 'Workspaces' button.
- tests: snapshot round-trip, persist CRUD + classify-only-preserves-tree,
copy-from-snapshot via mock root handle (28 classify/classifier tests green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Copy button (enabled once >=1 file is fully classified) copies the mapped
files into a user-chosen output directory under their canonical names/layout
<party>/{received,issued}/<transmittal>/<filename> — reading the source, never
writing it.
- copy.js: plan() (complete, non-excluded files) → conflict scan (two sources
→ same output path are reported + skipped) → copyTo() engine on the generic
FS-Access shape (ensureDir + getFileHandle + createWritable). Per-file dedup:
identical target (sha256) is skipped; existing-but-different is left
untouched and reported; live footer progress; completion toast.
- app.js: restores the saved map on launch (keyed by source-relative path, so
it re-attaches when the same directory is re-opened) and persists the source
handle on open; Copy button wired.
- target-tree.js: enables/labels the Copy button from the done count.
- 2 copy-engine tests with mock FS handles (copy/skip/differ + conflict);
24 classify+classifier tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Each source file row shows a classification state dot (unassigned →
has-tracking/transmittal → done), and each folder shows an aggregate dot
over its subtree.
- Right-click a file or folder to Exclude/Include from the copy (folder applies
to its whole subtree) or clear an axis; excluded files are struck through and
never copied.
- Cross-tree find is bidirectional: click a placed file in the target pane to
reveal+flash it in the source tree (expanding its folders); click a source
file to switch the target pane to its placed axis and flash the node.
- Target pane now reverse-looks-up over ALL scanned files (the left tree), not
the selection-scoped grid, with placements grouped in one pass per render.
- classify.getAssignment() read-only accessor; 5 new tests (18 total green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In Classify & Copy mode the left tree now lists each folder's files as
draggable rows (with a classification state dot), and folder rows are
draggable for a group-drag of the whole subtree. Target-tree nodes are drop
zones: a tracking folder (any node) or a transmittal bin; dropping assigns the
dragged source key(s) along that axis via classify.place().
- dnd.js: drag-payload bus (keys held in a module var since dataTransfer can't
be read during dragover; carries a marker for the copy cursor).
- tree.js: createFileElement + group-drag dragstart; classify-mode file rows.
- target-tree.js: setupDropZone with dragover highlight + drop assignment
(tracking = any node, transmittal = bins only).
- app.js: source tree re-renders on classify state change.
- 2 DnD drop-handler tests (14 total green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Header gets a Rename / Classify & Copy switch. In Classify & Copy mode the
spreadsheet pane is replaced by a tabbed target pane (By tracking number /
By transmittal), while the source tree stays on the left.
- target-tree.js: renders both trees from classify state; tracking-folder
create/rename/delete (leaf folders styled as the revision); party CRUD +
per-slot inline transmittal-bin form (date + TRN/SUB + seq + optional
status/title); shows the derived filename + a validation badge for each
placed file; live header stats (done / in progress / unassigned / excluded).
- app.js setMode(): swaps panes, toggles classify mode, re-renders both trees.
- 3 UI smoke tests added to classify.spec.js (12 total green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the non-destructive map+copy workflow: source stays read-only,
files are mapped onto two orthogonal target trees, a later step copies renamed
copies to a separate output dir.
- classify.js: the single source of truth. assignments map keyed by
source-relative path (survives re-pick); tracking tree (positional: ancestors
joined '-' = tracking number, immediate parent 'REV (STATUS)' leaf = rev+status,
title from original name) and transmittal tree (<party>/{received,issued}/<bin>).
deriveTarget() computes filename + output path + validation purely; pub/sub +
debounced autosave; node CRUD with dangling-placement cleanup.
- persist.js: IndexedDB store of the serialized map + the source
FileSystemDirectoryHandle, with queryPermission/requestPermission re-grant on
reload and a re-pick fallback.
- tests/classify.spec.js: 9 in-page unit tests for the derive/assignment logic
(no FS Access needed) — tracking join, leaf REV (STATUS) parse incl. invalid
status, title derivation/override, transmittal path composition, exclude,
cascade delete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the bespoke, chrome-less /.tokens page. It now renders through the
shared tables engine — getting the standard header (logo, theme, profile
menu) + declarative columns/filters for free — from a server-injected,
pre-assembled #table-context built from the user's tokens (Store.List).
New, reusable "tables over an API collection" primitive (tables/js/
api-actions.js): when the injected context carries an `apiActions` block,
it drives create (a modal form → POST, surfacing the one-time secret) and
per-row delete (→ DELETE) against a REST endpoint, and hides the file-model
toolbar affordances (+ Add row / Save). It deliberately does NOT touch the
file-save/row-ops machinery (ETag/conflict/row-file writes), so the secrets
surface stays on the existing tested /.api/tokens endpoints.
Server: handler.injectTableContextObj injects an arbitrary pre-assembled
context; EmbeddedTablesHTML() exposes the renderer to sibling handlers;
ServeTokensPage builds the token context (+ apiActions for /.api/tokens)
and serves the tables HTML, falling back to the legacy skeleton only when
the store or the tables renderer is unavailable.
This is the first dynamic/virtual-record collection rendered by the same
declarative engine + chrome as on-disk tables — no bespoke page. Validated
end-to-end in a containerized browser (list + create→secret + revoke);
tests/tokens.spec.js updated to the new UI; full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Menu refinements per review:
- "Open" now navigates into a folder (rescope); the separate "Navigate into"
item is removed. Zip → expand inline (can't navigate in); file → preview.
Inline expand stays on single-click / chevron / arrow keys.
- "New markdown file" → "New file".
- New folder / New file / Rename / Delete are now HIDDEN when the user lacks
the create/write/delete capability (folded into appliesTo) instead of shown
greyed — a guest gets a lean menu; users who can still see them. New
folder/file also remain on the toolbar.
- "Edit access rules…" is shown only when the user can actually edit them
(admin verb 'a' or subtree/site admin) — hidden otherwise, not greyed.
- Removed "Copy path" / "Copy name" — the info box (hovercard) carries the
name and a clickable URL now.
Info box (hovercard): dropped the on-disk "Path" row; the "URL" is rendered as
a clickable hyperlink (via the existing kvLink helper) — the shareable
reference, openable or right-click-to-copy.
Tests updated: file row omits New folder/file + Copy + Navigate; permission-
gated Rename/Delete are HIDDEN for a read-only server node and PRESENT for a
read/write/delete node (pure menuModel unit). All browse+conflict+diff green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the browse menu/tree interaction into a declarative, contextually
honest model and moves view settings onto a toolbar — the menu is the UI to
the system, so it should be familiar, inviting, and only ever offer what
applies.
New declarative menu model (browse/js/menu-model.js):
- Every action is one descriptor with a TYPE predicate (appliesTo) and a
CAPABILITY predicate (enabled)+tooltip. Row/pane menus are projections over
it; separators are derived from group changes. Designed data-shaped so a
future server-sourced manifest (zddc.zip) can supply/extend it.
- Hybrid visibility: type-inapplicable actions are OMITTED (New folder on a
file, Expand on a file); permission/role/tier-gated actions are SHOWN
DISABLED with a reason — so a lower tier sees what a higher role unlocks.
- Roles are NOT hardcoded: ordinary actions gate on the verbs the server
returns (node.verbs / path_verbs), so any operator-defined role works. Only
the two intrinsically-special tiers are recognised by name — site admin
(is_super_admin) and project/subtree admin (path_is_admin), surfaced as the
"Edit access rules…" item; both come from the existing /.profile/access.
- The headline fix: New folder / New markdown file no longer appear on file
rows (they target a folder or the current dir).
events.js: deletes the ~350-line inline buildTreeRowMenu/buildPaneMenu/
SORT_BY_ITEMS; opens menus via menuModel projections through one openRowMenuFor
/openPaneMenu path shared by right-click, the hover kebab, and the keyboard
menu key (ContextMenu / Shift+F10). Injects action impls via menuModel.configure
to avoid a circular dep. Prefetches the scope /.profile/access (memoised) on
load/rescope/refresh/popstate so menus never fetch at open time.
Discoverability + a11y: a per-row ⋯ kebab (tree.js + new icon-ellipsis sprite,
revealed on hover/selection/focus) opens the same menu; keyboard menu key
supported.
Toolbar: Sort + Show-hidden moved OUT of per-row right-click menus into the
tree-pane toolbar, plus New folder / New file buttons (act on the current dir,
greyed with a reason when create access is lacking). Help copy updated.
Icons: dropped the 3 stray emoji from menu items (consistent, VS Code/Finder
style); only new sprite is the kebab's icon-ellipsis.
Tests: +5 browse specs (file row omits New-folder; folder row shows it; a
read-only server node greys Rename with a "write access" tooltip via a pure
menuModel unit; toolbar Sort/Show-hidden drive state + New buttons present;
kebab and Shift+F10 both open the menu). All 23 browse+conflict+diff green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.
- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
(or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
the PUT response (so save→edit→save adopts the new version and doesn't
false-conflict); throws ConflictError (.status===412) on a precondition
failure so callers branch cleanly. New saveCopy() parks a conflicting edit
as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
from the content GET (the listing JSON carries no per-file etag); threaded
into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
(reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
adopt the returned etag on success, and on 412 open the dialog (Overwrite
re-fetches the current etag then re-saves — re-conflicts on a third writer
rather than blind-forcing; Reload clears dirty first so the renderInline
guard skips its confirm). FS-Access mode sends no precondition (no
concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
on 412, new-etag returned, force omits the precondition, dialog renders the
diff and each action resolves via its callback. Drives the fresh dist build
over file:// with a stubbed fetch (the test binary embeds the committed
browse.html, not dist, so a server-mode E2E would run stale code).
All browse + diff + conflict specs pass (18).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).
- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
transmittal/js/validation.js had no in-browser coverage. Add a spec for
both halves: the live #tracking-number aria-invalid binding (whitespace /
underscore) and validateBeforePublish() — a clean transmittal passes; a
tracking number with spaces/underscores fails and focuses the field; a
per-file bad tracking number or revision is flagged by row.
(The earlier audit's "transmittal is untested" was inaccurate — it already
has paste/FS round-trip, drag-drop, and init-state specs; this fills the
validation gap none of them covered.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The project-rollup forms derive originator from the selected Package
(party folder) server-side, so the field is read-only and was blank
until submit. Add a declarative `ui:mirrorFrom: <sibling>` hint: the
object renderer wires the named sibling's input to the field so the
read-only originator updates live as the user picks a party — the
composing tracking number is visible while filling the form. Display
only; the server stays authoritative via the cascade's folder_fields.
Set `ui:mirrorFrom: party` on originator in the embedded
default-project-{mdl,rsk}.form.yaml. Generic hint, not hardcoded field
names, so operators can reuse it.
Test: form-safety.spec.js — filling the source field updates the
read-only target; the target is not editable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- editor.js: suppress edit entry for cells whose schema is readOnly
(folder-bound originator, server-managed audit fields) — mirrors the
$-prefixed synthesized-column guard. The server overwrites these, so
inline-editing them was misleading and the value was silently lost.
- save.js createRow: on 201, re-fetch the written row so server-derived
fields (originator from the party folder, the composed tracking
number's components, audit stamps) surface immediately instead of
staying blank until reload. Falls back to the local merge if the GET
fails.
- save.js createRow: handle 409 (duplicate composed tracking number)
with a clear message on the sequence cell instead of the generic
errored state.
Test: tables.spec.js — a readOnly column doesn't mount an inline editor
while a normal sibling still edits. The 409 + re-fetch paths go through
the in-dir create POST (formCreateUrl), which the file:// Playwright
harness can't intercept; both are covered by the server e2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four root causes, each affecting one or more pre-existing
failures. All resolved without weakening any assertion.
1. build-label.spec.js (×4 — archive/transmittal/classifier/browse)
The regex accepted v<X.Y.Z>-alpha|beta channel labels but not the
-dev label modern dev builds emit. CLAUDE.md describes
v<X.Y.Z>-dev as the canonical dev-build form. Added |dev to the
channel alternation; tests now pass on dev builds and remain
tight on stable cuts.
2. landing.spec.js (×8)
SAMPLE_PROJECTS fixture pre-dated the post-reshape listing JSON
contract. The landing's loader now filters projects on
`is_dir: true`; the fixture didn't set it, so every entry was
filtered out and every "renders a project table" test failed at
the `.project-table` wait. Added `is_dir: true` (and trailing
slash on names, matching the live server's shape) to the three
fixture entries.
3. browse.spec.js (×1 — Download (zip))
The #downloadZipBtn toolbar button was retired in the SPA
overhaul (94b2e29) — Download ZIP moved to the right-click
context menu. Test still poked the dead toolbar button. The
picked-root folder no longer renders as a row (only its
contents do), so the test now scopes the assertion to
downloading a sub-folder (sub/) via right-click → Download ZIP;
verifies the zip's entries, magic bytes, and filename.
4. tables.spec.js (×1 — Phase 3 row-blur fires PUT)
Real bug, not a test issue. The editor's commit path tears down
its input element (clearing focus to body) before refocusing
the owning cell. main.js's focusout-on-#table-root handler ran
synchronously, saw `relatedTarget=null`, treated it as "user
left the grid", and fired flushAll() — racing the
selection-change save that fires from the subsequent
setSelected(r+1, c) inside the Enter handler. Net effect: two
identical PUTs per row-blur. Deferred the focusout check to
next tick via setTimeout(0); the cell.focus() inside the
editor's tearDown has time to settle, and the deferred check
sees document.activeElement still inside #table-root → skips
the redundant flush.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:
ssr/mdl/rsk tables rollups across parties with a
synthesised $party source-party column
working/staging/ browse folder-nav listings of parties with
reviewing non-empty content in the slot; per-party
URLs 302-redirect to archive/<party>/<slot>/
Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.
Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.
document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The on-page label for `--release alpha|beta` cuts used to read
`vX.Y.Z-channel · YYYY-MM-DD · word-word-word`. The three-word slug
(derived deterministically from the source SHA via shared/build-words.txt)
was meant to make "is this the same build you emailed about?" a glance
check, but the cute-words layer turned out to be more confusing than
clarifying — testers prefer a real timestamp + short SHA.
New label shape, identical to plain dev builds:
vX.Y.Z-channel · YYYY-MM-DD HH:MM:SS · <short-source-sha>[-dirty]
Helper renamed from _source_commit_slug to _source_commit_short_sha,
returning the short SHA of the source commit (walking past any
`chore(embedded): cut …` commit at HEAD so a re-cut on unchanged
source produces the same SHA). The wordlist file is no longer
referenced and is removed; tests/build-label.spec.js's regex
simplified to require the full timestamp + SHA form.
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.
Removed:
• mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
• zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
• tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
• mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
switch case in EmbeddedBytes)
• "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
error-message app list
• "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
• mdedit case in tests (handler_test.go, validate_test.go,
zddchandler_test.go) — test fixtures now use browse/classifier
• mdedit from build (per-tool build.sh loop, tool-list literals,
composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
• mdedit from freshen-channel's tool list and usage banner
• mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
Markdown Editor section in ARCHITECTURE.md rewritten to point at
browse/js/preview-markdown.js
• mdedit from CLAUDE.md, README.md, zddc/README.md tool lists
Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
`./build alpha|beta` now stamps a date + a three-word slug derived from
the source SHA (e.g. "v0.0.17-beta · 2026-05-12 · candle-mast-pearl")
instead of a raw hex SHA. The build-label spec's channel-label regex
only matched the hex-SHA form (still used by plain dev builds), so it
failed on every release cut. Accept either trailing field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A "⤓ Download (zip)" button in the browse toolbar (shown once a
directory is loaded) downloads the directory you're currently
viewing — and everything under it you're allowed to see — as a single
.zip. Navigate into a subfolder first to grab just that subtree.
- Server mode: an <a download> at "<currentPath>?zip=1" — zddc-server
streams the ACL-filtered zip (see the previous commit), nothing held
in the browser.
- Offline (file://) mode: new browse/js/download.js walks the picked
folder with the FS-Access API in two passes — metadata first (so it
can confirm() before loading >~2000 files / ~500 MB into memory),
then bytes — bundles with the already-vendored JSZip, and triggers a
blob download. Hidden entries (".":/"_"-prefixed) are skipped, the
zip's top level is "<folderName>/…" so it unpacks tidily, and the
status bar shows progress.
Wired in browse/js/events.js (button click + show/hide alongside the
refresh button); concatenated into browse/build.sh; ARCHITECTURE.md +
AGENTS.md note the ?zip=1 endpoint and the button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tests/browse.spec.js: expand a .zip in the file tree (offline), drill
into a member subdir, preview a text member — exercises shared/zip-source.js
and the migrated offline path end to end.
- tests/archive.spec.js: a .zip whose name parses as a transmittal folder
is scanned like an uncompressed one — members land in the file list with
tracking numbers parsed, tied to the zip transmittal's folder.
- tests/fixtures/mock-fs-api.js: __setMockDirectoryTree now keeps binary
leaf values (Uint8Array/ArrayBuffer/Blob) intact instead of String()-ing
them — needed to feed real zip bytes through the mock FS.
- tests/data/test-archive.sh: each party gets one transmittal delivered as
a single .zip in received/, so the bitnest fixture exercises the
zip-as-virtual-directory path.
- ARCHITECTURE.md / AGENTS.md: document .zip-as-navigable-directory (server
route + ACL model + shared client adapter + the one-level nesting limit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.
Schema added:
available_tools: [tool1, tool2, ...] concat-union across cascade;
tools not in the union are
denied auto-route at that path
auto_own_fenced: true|false generated auto-own .zddc
carries inherit:false (private
to creator)
Lookups added:
AvailableToolsAt(root, dir) union of available_tools across cascade
IsToolAvailableAt(root, dir, tool)
AutoOwnFencedAt(root, dir) leaf-only
Cascade semantics finalised (per field):
default_tool → leaf→root walk (parent applies to descendants)
available_tools → leaf→root union (each level adds; baseline at root)
auto_own → leaf-only (creating THIS dir specifically)
auto_own_fenced → leaf-only (same)
virtual → leaf-only (THIS dir is virtual, not subtree)
Consumers migrated:
apps.DefaultAppAt → zddc.DefaultToolAt
apps.AppAvailableAt → zddc.IsToolAvailableAt (+ landing special)
EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
fs.ListDirectory empty-list fallback → zddc.IsDeclaredPath
fs.virtualCanonicalFolders → zddc.ChildrenDeclaredAt
dispatcher canonical-folder branches → unified into one
cascade-declared block
Hardcoded helpers REMOVED (dead code):
apps.inAncestorWithName
zddc.autoOwnDepthMatch / isAutoOwnDepthMatch
Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
is used by special.go's IsProjectRootFolder. The rest are dead.
Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
- no-slash, default_tool=tables → ServeTable (default-MDL fallback)
- no-slash, default_tool set → apps.Serve(tool)
- no-slash, no default_tool → 302 to slash form
- slash, any → ServeDirectory empty-list fallback
The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.
defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.
Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.
Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coupled fixes:
1. landing MDL card: Open button now navigates to /<project>/archive/
<party>/mdl (no trailing slash) so the tables tool loads. The
slash form would route to browse instead, which is not what users
want when they click "Open MDL".
2. zddc-server canonical-folder fallback extended to
archive/<party>/{mdl,incoming,received,issued}. New
zddc.IsArchivePartyFolder() recognises any of the four party
folders at depth 4. fs.ListDirectory returns [] for missing
on-disk variants (mirroring the project-root behavior added in
commit 3fc3717); the dispatcher routes slash forms to
ServeDirectory and the no-slash mdl form to ServeTable, with
non-mdl no-slash forms 302'ing to the slash form.
So /Project-N/archive/<party>/incoming/ now lands on an empty
browse listing rather than 404 when nobody has dropped files yet.
3. Fixture seeded with 3 files per party under incoming/ — naming
intentionally NOT in transmittal-envelope form, so classifier
(loaded automatically by browse's grid mode at /incoming/
per the URL-driven view convention) has something to rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:
1. Test fixture migrated to lowercase canonical folder names.
tests/data/test-archive.sh now creates archive/, received/, issued/
on disk. Three projects also get human-friendly .zddc titles
("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
a display: override demonstrating the new map. Party names
(PartyA/B/C) stay unchanged — non-canonical.
2. New .zddc display: schema. Maps a child entry's on-disk name to a
human-friendly label. The on-disk name stays canonical (lowercase
for project-root folders); only the rendered label changes. Match
is case-insensitive. Example:
display:
archive: "Records"
working: "In-Progress"
No upward cascade — a parent .zddc doesn't relabel grand-children;
each directory sets display: on its own children.
3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
the directory's .zddc display map and stamps DisplayName per entry.
The field is omitempty so listings without overrides stay
byte-identical to before.
4. Virtual canonical project-root folders (archive/working/staging/
reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
project root where the on-disk variant is absent in any case. This
replaces the client-side injection in browse and lets the display:
map apply to virtual entries the same way it applies to real ones.
Browse drops its withVirtualCanonicals helper; the loader carries
display_name through from the server's listing.
5. Archive app project picker dropdown shows the .zddc title of each
project (sourced from ProjectInfo.Title in the server's project
list), falling back to the folder name when no title is set. When
they differ, the folder name is rendered in muted mono after the
title for traceability. data-name still carries the canonical
folder name so URL state stays stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The previous nested-flexbox layout produced indeterminate heights
inside the Toast UI editor host and made the TOC pane width fragile —
visually the editor and outline weren't laying out reliably. This
swaps the whole shell to CSS Grid, which gives every cell a definite
size.
Layout:
┌──────────────────────────────────────────────────────────────┐
│ toolbar (Save | ● modified | status | source) │
├─────────────────────────────────────┬────────────────────────┤
│ │ Outline │
│ Toast UI Editor │ • Heading 1 │
│ (md / wysiwyg / preview) │ • Subheading │
│ ├────────────────────────┤
│ │ Front matter │
│ │ title: … rev: … │
└─────────────────────────────────────┴────────────────────────┘
Notes:
- The shell mounts as a single child of #previewBody (not by
re-classing previewBody itself), so the outer flex layout that
fills the preview pane is preserved.
- Sidebar is its own grid (outline 1fr + front-matter auto/max 40%),
each section independently scrollable.
- Resizer is a 6 px element on the grid column boundary; drag
updates grid-template-columns. Keyboard left/right adjust by 24 px.
Width persists across mounts (lastTocWidth) within a session.
- parseHeadings now skips front-matter envelope + fenced code so a
"##" inside ```bash``` doesn't show up as an outline entry.
- scrollEditorToHeading uses findScrollParent + scrollTo({behavior:
'smooth'}) so jumps feel less jarring.
- Class names follow BEM: .md-shell__*, .md-side__*, .md-toc__*,
.md-fm__*. Tests updated to the new selectors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Master Deliverables List section was a long prose block ("To edit
the MDL: 1. open the archive, 2. click into a party folder, 3. click
mdl…") followed by a bullet list of party links — visually
inconsistent with the four stage cards above it.
Replaced by a fifth card in the .stages grid styled like the others:
heading + short description + an inline select + Open button. The
select populates from the same fetchParties() helper that backed the
old <ul.party-list>; selecting a party + clicking Open navigates to
/<project>/archive/<party>/mdl/.
Empty/error states:
- No parties yet: select shows "(no party folders yet)"; hint copy
expands to explain the URL-based fallback (zddc-server still
auto-renders archive/<party>/mdl/ even when the folder is missing).
- Network error: select shows "(could not enumerate parties)"; user
can navigate via the URL bar.
Updated landing.spec.js — the old "lists existing parties as direct
MDL links" test now asserts on #mdlPartySelect contents + click-to-nav.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Markdown preview pane now surfaces YAML front-matter above the TOC as a
key/value list (definition list), so engineering documents with header
metadata (title, revision, status, etc.) show their identity at a glance
without opening the file in mdedit. Front-matter parsing handles both
scalar and array values; arrays render as comma-joined.
TOC pane is now resizable (4px col-resize handle on its left edge);
preserves the user's chosen width across re-renders inside a single
session.
mdedit welcome banner moved inside #welcome-screen so the "browse opens
md in this same editor" callout only shows when no file is open — it
was previously visible in every state which was noisy.
archive.spec.js: wait for #filePreviewToggle to be attached before
clicking, fixing a Playwright flake where the preview button hadn't
mounted yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the markdown plugin's deferred v2 items:
1. TOC pane
A third pane to the right of the Toast UI editor lists every heading
in the current document, hierarchically indented by level. Click an
item → editor scrolls to that heading (markdown-mode uses
setSelection + preview scroll; WYSIWYG mode uses DOM text matching;
the target heading flashes briefly via primary-light background).
The TOC re-renders on every editor change (debounced 250ms) so it
stays in sync with edits.
Heading parser supports ATX-style `^#{1,6}\s+` lines, strips inline
markdown emphasis/code/links/strike from the displayed label.
Empty file → "Empty file." Headingless file → "No headings."
2. FS-API writes
Saves now route to whichever source the file came from:
- node.handle + createWritable available → FileSystemWritableFileStream
(local folder picker). The user's chosen file gets overwritten
via the browser's File System Access API.
- node.url + server source → PUT to the server URL (as before).
- zip-virtual file → save disabled (no writable stream from JSZip).
- Anything else → save disabled with a tooltip.
Save status surfaces via the existing toolbar (`Saved 10:42:18`) AND
a shared toast notification ("Saved readme.md" / "Save failed: …")
so the success/failure is visible regardless of whether the user is
looking at the toolbar.
Source-hint chip on the toolbar shows "local" / "server" /
"read-only (inside zip)" so the user knows which write path is
active before they make changes.
CSS additions in browse/css/tree.css for .md-toolbar, .md-split,
.md-editor-host, .md-toc-pane, .toc-list, and the .toc-level-1..6
indentation rules.
A new Playwright test exercises the markdown plugin end-to-end:
mounts the editor on a .md click, asserts the three DOM regions are
visible, verifies the TOC contains the three expected headings from
the test fixture's markdown content, and confirms the source hint
reads "local" for FS-API mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape browse from "tree-as-table with popup preview" into a unified
file-experience tool with three layered behaviors:
Phase A — Two-pane shell
Phase B — Markdown plugin (Toast UI inline)
Phase C — Grid mode (classifier workflow)
Phase D — Deprecation banners on standalone classifier + mdedit
= Phase A: two-pane shell + lightweight preview plugins =
Browse's table view becomes a tree-pane on the left + preview-pane on
the right with a draggable resizer. Click a folder → expand inline.
Click a file → render in the right pane. The previous popup window
becomes an explicit "⤴ Pop out" button in the right-pane header for
users with a second monitor.
Preview rendering reuses shared/preview-lib.js (PDF iframe, image
<img>, TIFF, ZIP listing, text <pre>). Unknown types show a download
link. browse/js/preview.js refactored into renderInline (default) +
renderInPopup (Pop out button); both share the same plugin
dispatch logic.
Filter rows were already removed earlier this session. Sort columns
likewise — the tree is alphabetical by default; the underlying
setSort API still exists for future re-introduction.
= Phase B: markdown plugin =
New browse/js/preview-markdown.js: when a .md or .markdown file is
clicked, the right pane mounts a Toast UI editor (initial-value =
file contents) with a small toolbar containing Save + dirty indicator
+ status text. Save sends PUT through the file API for server-mode
files; non-server sources are read-only for now (deferred to a
follow-up that wires zddc-source.js writes too). Ctrl+S / Cmd+S
inside the editor saves.
Toast UI Editor (~700 KB JS + ~160 KB CSS) was previously bundled
only in mdedit/vendor/. Moved to shared/vendor/ so browse and mdedit
both pull from one location.
= Phase C: grid mode =
View-mode toggle [Browse | Grid] in the toolbar. Grid mode loads the
classifier tool as an iframe scoped to the current directory (server
mode at working/staging/incoming locations) — classifier's full
bulk-rename workflow without leaving browse. v1 implementation; a
future iteration could bundle classifier's modules directly into
browse for tighter integration. Hostile cases (file:// origin, paths
outside working/staging/incoming) show a friendly explanation
instead of a blank iframe.
new browse/js/grid.js handles the activation logic.
= Phase D: deprecation banners =
mdedit and classifier standalones gain a "this tool is being absorbed
into Browse" advisory banner. Both standalones remain fully
functional and continue to ship — they're useful for offline single-
file editing and air-gapped environments. The banner just points
users toward the unified browse experience.
= Files =
+ browse/js/preview-markdown.js (markdown plugin)
+ browse/js/grid.js (grid-mode plugin)
M browse/template.html (two-pane layout, view toggle, banners)
M browse/css/tree.css (two-pane CSS, replaces table styles)
M browse/js/init.js (state additions: selectedId, viewMode)
M browse/js/tree.js (rowHtml: <tr>+<td> → <div>)
M browse/js/preview.js (renderInline / renderInPopup split)
M browse/js/events.js (toggle wiring, resizer, click handlers
adapted from <table> to <div>)
M browse/build.sh (Toast UI vendor + new modules)
R mdedit/vendor/toastui-* → shared/vendor/ (one bundle, two tools)
M mdedit/build.sh (paths)
M mdedit/template.html (deprecation banner)
M classifier/template.html (deprecation banner)
M tests/browse.spec.js (selectors updated for new layout +
new "click file → preview" test)
Bundle sizes after this commit:
browse: ~1020 KB (was ~290 KB; added Toast UI ~700 KB)
classifier: ~1470 KB (unchanged from prior baseline)
mdedit: ~2140 KB (unchanged; vendor location moved but not added)
What's deferred:
- TOC + front-matter pane in browse's markdown plugin (mdedit has
these; browse v1 uses just the editor).
- FS-API writes from browse's markdown plugin (server PUT works).
- Classifier modules bundled directly into browse (v1 uses iframe).
- Sort UI in the new tree (model still supports it; no widget yet).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more half-contract leaks surfaced by sweeping shared chrome class
usage across tools:
1. form/ had its own design-token namespace (--color-primary,
--color-bg, --color-text, --color-border, --color-bg-alt,
--color-text-muted) defined nowhere but fallback'd to hardcoded
hex values different from shared (#1e3a5f vs shared's #2a5a8a).
Form's buttons, inputs, fieldsets, array-row borders, and status
colors all rendered with subtly different palette than the rest
of the suite.
Form also redefined .btn, .btn-primary, and a (form-local)
.btn-small class — the redefinition shadowed shared/base.css's
button system entirely. Form's JS used 'btn btn-small' for the
add/remove row buttons in form-array widgets.
Fixes:
- form/css/form.css: rename every --color-* reference to the
matching shared token (--primary, --bg, --bg-secondary, --text,
--text-muted, --border, --radius, --danger, --success).
- form/css/form.css: delete the .btn / .btn-primary / .btn-small
blocks entirely. Shared covers .btn / .btn-primary /
.btn-secondary / .btn-sm / .btn-lg / .btn-link.
- form/js/array.js: switch the row add/remove buttons to
'btn btn-sm btn-secondary' so they pick up shared's sizing
and outline variant.
- tests/form-safety.spec.js: update the selector
button.btn-small → button.btn-sm.
2. browse/ had .hidden { display: none !important; } — exact
duplicate of shared/base.css's rule. Delete the redundant copy
(left a one-line comment pointer in case anyone wonders why
it's missing from the local sheet).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /<project> landing page was server-rendered via
internal/handler/projecthandler.go's html/template — an inconsistency
against the project's "every tool is a single-file HTML" convention.
Convert it to a mode of the existing landing/ tool: same bundle now
serves both / (project picker) and /<project> (project workspace).
Mechanics:
- landing/template.html: pickerView (existing markup) + projectView
(new: stage cards, browse-all, MDL section, party-list slot).
Mode toggles by adding/removing .hidden on the two containers.
- landing/js/landing.js: detectMode() reads location.pathname;
renderProjectMode() populates stage hrefs from the project segment
and fetches /<project>/archive/?json=1 for the party list. init()
forks based on mode; picker init was extracted to initPicker().
Existing public API + behaviour unchanged for picker mode.
- landing/css/landing.css: appended ~115 lines for the project view
(.stages grid, .stage-card hover, .party-list, MDL formatting).
- cmd/zddc-server/main.go: dispatcher's IsProjectRootURL fork now
calls appsSrv.Serve(w, r, "landing", chain, absPath) rather than
the deleted ServeProjectLanding handler.
- internal/handler/projecthandler.go: trimmed to just the
IsProjectRootURL predicate (the dispatcher still needs it for
routing). Template + render code (~220 lines) deleted.
Net effect: same UI as before — same logo wrapping (now via
shared/logo.js, no longer a hand-rolled inline anchor), same stage
cards, same MDL instructions with party links — but the page is now a
single-file SPA that themes like the rest, follows the same logo and
stage-strip conventions, and could in principle be downloaded and
served standalone.
Tests:
- 3 new tests/landing.spec.js cases: detectMode exposure, project
workspace renders at /<project> with correct stage hrefs + title,
party listing populates from JSON fetch and filters dot-prefixed
entries.
- The dispatcher test for /Project no-slash still asserts 200 +
no-redirect; the served body is now landing.html instead of the
server-rendered template, but both pass the assertion.
LOC: roughly net-neutral. -220 in projecthandler.go, +115 in
landing.css, +130 in landing.js, +60 in template.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .app-header__logo SVG was decorative on every tool. Web's
strongest convention is "click logo → go home" — so users tapping
it expecting that fallback got nothing. Now the logo is wrapped in
an anchor whose href reflects the URL the page was loaded from:
file:// → no wrap (no server home to point at)
/ → wrap, href=/ (deployment root)
/index.html / /<tool>.html → wrap, href=/ (root, no project)
/<project>/... → wrap, href=/<project> (project landing)
The wrap happens client-side at DOMContentLoaded via shared/logo.js,
loaded by every tool's build.sh after toast/nav. Idempotent — a
template-supplied anchor or a second mount call is a no-op.
The companion shared/logo.css adds a subtle hover/focus affordance
(opacity 0.82, focus ring) so the logo reads as clickable without
otherwise altering its visual weight. Tools opt out by setting
window.zddc.logo.disabled = true before DOMContentLoaded (e.g. for
deployments that pin the logo to an external destination).
Five Playwright tests (tests/logo.spec.js) lock the contract:
no-wrap on file://, href=/ at root, href=/<project> in project
subtree, aria-label matches target, idempotent re-mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a thin nav strip directly under the app-header showing the four
canonical lifecycle stages from the transmittal-workflow spec:
archive · working · staging · reviewing. Each is a link to that
stage's directory under the current project. Current stage is
highlighted (bold + primary color, aria-current="page"). Strip
mounts as a sibling of .app-header on DOMContentLoaded — no
template changes needed in any tool.
Render rules (shared/nav.js shouldRender):
- location.protocol must be http: or https: (file:// has no project
structure to navigate within)
- a project segment must be detectable as the first path segment
(when it isn't a tool HTML file like /index.html or
/archive.html?projects=A,B). Multi-project view at the deployment
root therefore shows no strip.
Stage URL targets:
- Archive → <project>/archive.html (project-root archive view)
- Working → <project>/working/ (directory listing — mdedit auto-served)
- Staging → <project>/staging/ (directory listing — transmittal auto-served)
- Reviewing → <project>/reviewing/ (directory listing)
Convention-driven, not probed: if a deployment doesn't have one of
these folders the link returns 404. Operators on non-standard layouts
can opt out by setting window.zddc.nav.disabled = true before
DOMContentLoaded.
This pairs with the previous landing-tool change (single-project
click → <project>/archive.html). Together they give the user
both URL-bar manipulation AND visible navigation across the four
canonical project stages.
Five Playwright tests in tests/nav.spec.js exercise:
- non-render at deployment root
- render + active stage on <project>/archive.html
- render + active stage deep inside <project>/working/foo/mdedit.html
- canonical link targets
- mount position is sibling of .app-header
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously every project click — single or group — built
archive.html?projects=<list> and let the archive tool's URL-state
detection fan out from there. For a single project that's a
single-page-app trick that obscures the canonical URL.
Now single-project clicks navigate to <project>/archive.html instead.
The benefit is direct URL manipulation: the user can swap archive.html
for working/, staging/, reviewing/, archive/<party>/mdl/table.html etc.
in the address bar without going back through landing. zddc-server's
availability.go already auto-serves the right tool at each canonical
folder, so the destinations resolve without any server change.
Multi-project clicks (groups) keep the ?projects=A,B form because
there's no single subtree root. ACL-trimmed groups that collapse to
one project also take the new single-project path, since the result
is effectively a single-project view either way.
The ?v= channel selector continues to carry across both paths.
Two existing landing.spec.js assertions updated to match the new
single-project URL shape; multi-project assertion unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote classifier's local toast (classifier/css/base.css + showToast
in classifier/js/excel.js) into shared/toast.{js,css}. Every tool's
build.sh now concatenates them, so window.zddc.toast(msg, level, opts)
is callable from any tool.
API:
window.zddc.toast('Saved.', 'success');
window.zddc.toast('Could not load: ' + err.message, 'error');
window.zddc.toast('Note', 'info', { durationMs: 3000 });
Levels: info (default) | success | warning | error. Single-toast
policy — a second call replaces the first. Click anywhere on the
toast to dismiss. ARIA: error → role=alert/aria-live=assertive,
others → role=status/aria-live=polite.
Class prefix is .zddc-toast (BEM-ish) to avoid colliding with any
tool-local .toast rules. Classifier's existing showToast now
delegates to window.zddc.toast — call sites in excel.js +
selection.js are unchanged. Classifier's local .toast CSS block
deleted in favor of the shared one.
This commit only EXPOSES the API. Replacing the ~25 alert() call
sites scattered across archive/transmittal/mdedit/classifier with
toast calls is left as follow-up — each alert needs per-call review
to decide if it's truly non-blocking.
Five Playwright tests in tests/toast.spec.js lock the contract:
API exposure, level mapping, ARIA roles, single-toast replace,
click-to-dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>