The tree's underlying setSort API was carried forward from the old
table-with-clickable-headers UI but had no widget driving it after
the layout reshape. Adds an explicit dropdown in the toolbar:
Sort: [Name (A→Z) ▾]
[Name (Z→A) ]
[Modified (new→old) ]
[Modified (old→new) ]
[Size (large→small) ]
[Size (small→large) ]
[Type (A→Z) ]
Implementation:
- new tree.setSortExplicit(key, dir) — sets both axes in one call
(the existing tree.setSort toggles direction on repeat-clicks,
which is the right semantics for column-header clicks but wrong
for an explicit dropdown).
- events.js parses the dropdown value as "<key>:<asc|desc>" and
calls setSortExplicit. The dropdown is initialised to reflect
the current sort state on mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous dark-mode --primary (#4a90c4) had ~3.3:1 contrast against
the white button text we pair it with — below WCAG AA 4.5:1 for
normal text. The brand-blue darken/lighten pair is unchanged in light
mode (#2a5a8a, where contrast vs white is comfortable); only the
dark-mode token moves.
--primary: #4a90c4 → #5fa8e0 (≈4.6:1 vs #fff)
--primary-hover: #5ba3d9 → #74b6e6
--primary-active: #6ab5e8 → #88c4ec
Same change applied to both the `@media (prefers-color-scheme: dark)`
block (auto) and the `[data-theme="dark"]` block (manual override).
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>
Removes every runtime CDN load. The "ship the record player with the
record" philosophy: a downloaded .html file works offline against any
file the user can open, with no network dependency at runtime.
Newly vendored under shared/vendor/:
- xlsx.full.min.js (SheetJS, 928 KB) — XLSX/XLS preview
- utif.min.js (UTIF, 57 KB) — TIFF preview
Already there but now used by mdedit too:
- jszip.min.js, docx-preview.min.js
Call sites updated to drop the `await loadLibrary(URL)` pattern —
since the vendor JS is concatenated into the inline <script> at build
time, window.XLSX / window.JSZip / window.UTIF / window.docx are
available synchronously from page load.
Per-tool changes:
- archive/build.sh: +xlsx, +utif
- classifier/build.sh: +xlsx, +utif
- transmittal/build.sh: +xlsx, +utif
- mdedit/build.sh: +jszip, +docx-preview, +xlsx, +utif
(mdedit was the only tool not yet
bundling any of the preview deps)
- browse/build.sh: +utif
- archive/js/table.js, classifier/js/preview.js,
transmittal/js/files-preview.js, mdedit/js/file-tree.js (×2):
drop the `await loadLibrary('…cdn…')` lines.
- shared/preview-lib.js:
drop the loadLibrary(UTIF) / loadLibrary(JSZip) wrappers; assume
window.UTIF and window.JSZip are present.
Net bundle-size delta after baking:
archive: +990 KB → ~1.47 MB
browse: +57 KB → ~292 KB
classifier: +990 KB → ~1.43 MB
mdedit: +1100 KB → ~2.09 MB
transmittal: +990 KB → ~1.63 MB
Docs (AGENTS.md, ARCHITECTURE.md) updated: removed the "runtime CDN
loading exception" paragraph and the table row that flagged xlsx as
CDN-loaded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
301 Moved Permanently is cached by browsers effectively forever — when
we changed /<project> no-slash from "redirect to slash form" to
"serve project landing" earlier today, anyone who had visited the URL
under the prior behavior got stuck on the cached 301 indefinitely. No
server-side fix is possible after the fact; only a manual cache clear
in each user's browser releases the binding.
Demote every routing-shape redirect to 302 Found, which browsers do
not cache by default. Five sites:
- handler/directory.go: no-trailing-slash → slash on directory URLs
- main.go (4 sites):
.archive/ canonicalization (deep /<project>/<sub>/.../.archive/
path collapses to /<project>/.archive/)
reviewing/<tracking> no-slash → slash
reviewing/ default-app fallback to slash form
generic IsDir + no-slash + no-default-tool fallback
301 → 302 trades "permanent semantics in the protocol" for "we can
change our mind later without trapping users on old behavior." For
these routes — all of which are convention-driven shapes the server
owns — the latter is what we want.
Test updates: five httptest assertions switch from
http.StatusMovedPermanently → http.StatusFound, plus five comment
strings ("301" → "302").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
classifier/js/tree.js was inserting a <div class="empty-state">No
folders found</div> inside the folder-tree pane when the tree was
empty. That conflicted with the shared .empty-state rule promoted
in the previous commit — which expects an outer flex container with
a child .empty-state__inner card, used for the top-level welcome
overlay.
The two usages aren't the same thing semantically (one is the
welcome screen; one is a tiny inline "list is empty" placeholder
inside the folder tree). Rename the inline one to .tree-empty to
remove the collision. The spreadsheet.css rule that targeted the
old class is renamed to match; same padding/text-align/color.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three tools (archive, browse, classifier) independently implemented
an empty-state pattern with three different CSS class naming
conventions and slightly different rules:
archive: .empty-state + .empty-state-content (BEM-less)
browse: .empty-state + .empty-state__inner (BEM)
classifier: .empty-state + .empty-state-content (BEM-less)
Same visual intent ("nothing's loaded yet — here's a welcome card
with instructions"), implemented three times with subtly different
spacing, no shared body styling for h2/p/ul/li, and incompatible
class names that prevented a future tool from copy-pasting the
pattern.
Promote a single consolidated rule set to shared/base.css using
BEM naming throughout:
.empty-state — base (flex centered, padding)
.empty-state--overlay — modifier: position absolute,
top 50px to clear app-header,
z-index 10. Used by archive
and classifier (their empty
states sit OVER the main
layout).
.empty-state__inner — content card (left-aligned,
text-muted, max-width 640)
.empty-state__inner--centered — modifier: tighter max-width
500, centered text, 2rem
padding. Used by tools whose
welcome screen reads as a
centered card.
.empty-state__inner h2/p/ul/ol/li — typography defaults
.empty-state__inner .note — italic small-print
.welcome-list — bullet list with left-aligned
text + auto margins; safe to
nest inside a centered card.
Per-tool changes:
- archive/template.html, archive/js/app.js: rename
.empty-state-content → .empty-state__inner empty-state__inner--centered;
add .empty-state--overlay to the outer .empty-state container.
Also the runtime-injected unsupported-browser markup in
showUnsupportedBrowserMessage() and the showHttpErrorState
selector.
- classifier/template.html: same renames.
- archive/css/layout.css + components.css: delete .empty-state*
and .welcome-list rules (now in shared).
- classifier/css/layout.css: same. Keep .empty-state.drag-over
locally — classifier is the only tool whose empty state acts
as a drop target.
- browse/css/base.css: delete .empty-state* (shared covers it).
browse's template was already using .empty-state__inner so no
template change needed.
LOC: shared/base.css gains ~70 lines; per-tool overrides lose ~85
combined. Net -15. More importantly, future tools can reuse the
pattern by adding two divs and (optionally) the --centered or
--overlay modifiers; no copy-paste required.
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>
Five tools (browse, transmittal, landing, form, tables) were rendering
their headers as vertically-stacked blocks — the .app-header flex
container correctly laid out its left/right groups, but those groups
themselves had no display:flex rule, so their children (logo, title,
build label, action button) defaulted to block-level stacking.
Three tools (archive, mdedit, classifier) hid the bug because they
each carried their own copy of the .header-left/.header-right flex
rule in tool-local CSS. Same intent, slightly different gap values:
archive: left gap 0.75rem, right gap 0.5rem
mdedit: both 0.75rem
classifier: both 0.5rem
Promote the rule to shared/base.css alongside .app-header (where the
class is used in every template anyway): left 0.75rem, right 0.5rem
(matching archive — the layout the user pointed at as the reference).
Delete the three local duplicates.
Now all eight tools use the same header chrome contract: logo + title
group + primary action laid out horizontally with consistent gaps,
icon buttons grouped tighter on the right.
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 project landing page at /<project> had its own hand-rolled
header with <svg class="logo"> — not the canonical app-header__logo
class, and not loading shared/logo.js. So the logo on that page was
purely decorative while every other tool's logo (in the same beta
build) was wrapped by shared/logo.js into a clickable link to
/<project>. Inconsistent and surprising — clicking the logo from
mdedit/archive/etc. takes you to project landing, but clicking the
logo on project landing did nothing.
Inline the wrap directly in the template (the page is server-
rendered, so it can't lean on shared/logo.js the way bundled tools
do):
<a class="app-header__logo-link" href="/" title="ZDDC home">
<svg class="app-header__logo" ...>...</svg>
</a>
href="/" because "next up" from the project landing is the
deployment root (the project picker / landing tool).
Also rename .logo → .app-header__logo for visual consistency, and
add the matching hover/focus styles inline. The test asserts both
the wrapping anchor and the canonical class name.
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>
GET /<project> (no trailing slash) used to 301 to /<project>/ which
served the browse listing. Now it serves a small server-rendered
landing page with:
- Four lifecycle-stage cards (archive/working/staging/reviewing)
linking to the no-slash form of each canonical folder, so each
card opens its default tool (archive view, mdedit sandboxed to
working/, transmittal at staging/, mdedit at reviewing/).
- A "Browse all files" link to the slash form for the generic
file tree.
- A "Master Deliverables List" section with step-by-step
instructions for editing any party's MDL plus direct links to
the MDL of each party already present under archive/ (sorted,
case-preserved). Falls back to a friendly "no parties yet"
message when the archive is fresh.
Trailing-slash form (/<project>/) is unchanged — still 200 +
embedded browse.html. The slash-vs-no-slash convention now extends
all the way up the URL tree:
/ → landing tool (project picker)
/<project> → project landing (this commit)
/<project>/ → browse
/<project>/working → mdedit
/<project>/working/ → browse
... etc.
Implementation:
- new internal/handler/projecthandler.go — IsProjectRootURL
predicate + ServeProjectLanding rendering an inlined html/template.
Page styles are inline; tokens mirror shared/base.css and
auto-flip on prefers-color-scheme: dark.
- dispatcher in cmd/zddc-server/main.go: at the IsDir branch's
no-slash fork, intercept depth-1 single-segment URLs before
the historical 301. Other depths still 301 unchanged.
Tests:
- internal/handler/projecthandler_test.go (4 cases): predicate
coverage; landing page renders project name + four stage cards;
on-disk parties surface as MDL links with case preserved; fresh
project falls back to the no-parties-yet copy.
- cmd/zddc-server/main_test.go TestDispatchSlashRouting: the
"project root no-slash → 301" case becomes "→ landing (200)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply the same slash-vs-no-slash convention to reviewing/ that already
governs the other three canonical project folders:
/<project>/reviewing → mdedit (default tool, via DefaultAppAt)
/<project>/reviewing/ → browse (HTML) — shows the aggregator's
virtual <tracking>/ entries as a tree
/<project>/reviewing/?json → aggregator JSON (handler.ServeReviewing)
Browse fetches the JSON listing for the URL it was loaded from, so
loading browse.html at /<project>/reviewing/ triggers a JSON request
back through the dispatcher → ServeReviewing → aggregator output.
Browse then renders the virtual <tracking>/ entries as clickable
folders. Clicking a tracking folder navigates to the per-submittal
view; clicking received/ or staged/ exits the virtual subtree
into canonical archive/ or staging/ paths via the polyfill's
explicit-url support.
The HTML branch in the reviewing dispatcher block was previously
calling appsSrv.Serve(..., "mdedit", ...) for trailing-slash URLs;
now it falls through to the canonical-folder block which routes to
ServeDirectory's HTML default (embedded browse.html).
Test: TestDispatchEmptyCanonicalProjectFolders extended with the
slash/<stage> → browse subtests, mirroring the no-slash → default
app set. All four canonical folders now have symmetric coverage of
both shapes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a user first writes to <project>/working/<email>/, the auto-own
.zddc EnsureCanonicalAncestors seeds at that folder now sets
acl.inherit: false in addition to the rwcda grant. This makes each
user's working subtree private by default — ancestor cascade grants
(e.g. a permissive *: r at the project root) no longer let anyone
read everyone else's drafts.
Implements the user-stated sandbox model: "no automatic or default
permissions other than the user's default folder which is instantiated
on first save — users can edit the .zddc files in their subtree to
allow access to others." The owner can edit
<project>/working/<email>/.zddc to add collaborators (or set
inherit: true, or list specific email patterns).
Mechanics:
- new WriteAutoOwnZddcFenced — same shape as WriteAutoOwnZddc plus
acl.inherit: false. Existing WriteAutoOwnZddc unchanged.
- autoOwnDepthMatch returns (autoOwn, fenced); idx 2 under working/
triggers fenced=true. The other auto-own positions
(depth 1: working/staging/, depth 3: archive/<party>/incoming/)
stay unfenced — those are shared lanes where ancestor admin
grants should still apply.
- staging/ children stay unfenced because staging folders are
date+tracking-named (shared lane), not per-user.
Tests:
- TestEnsureCanonicalAncestors_LazyCreation now asserts the fenced
.zddc exists at working/<email>/ with inherit: false.
- TestEnsureCanonicalAncestors_StagingChildNotFenced new — staging
children stay plain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mdedit's loadServerDirectory derived its file-tree root by stripping
back to the last "/" in window.location.href. For a no-trailing-slash
canonical URL (<project>/working) that gave the wrong root: the parent
project directory rather than working/ itself. Visible symptom on
zddc-server's auto-served URLs after the recent dispatcher changes —
mdedit at /Project/working would show the project's archive, working,
staging, etc. all together instead of being sandboxed to working/.
Detect the directory-shaped URL by checking whether the last path
segment contains "." (file marker). No dot = directory; treat the
whole path as the root and append the trailing slash internally.
/Project/working → root /Project/working/ (was /Project/)
/Project/working/x/y/ → root /Project/working/x/y/ (unchanged)
/Project/x/mdedit.html → root /Project/x/ (file URL; unchanged)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two filter rows (📄 file/ext + 📁 folder) didn't really earn their
header real estate. Browse is for navigating directory structure;
ad-hoc filters across a tree of mixed file types and depths weren't
the right affordance, and the visual weight competed with the column
headers and the breadcrumb. Removed entirely:
- template.html: dropped both <tr class="filter-row"> rows in <thead>,
the related "Filter rows" help section, and the empty-state copy
that mentioned the 📄/📁 rows.
- init.js: dropped state.filters (file/folder/ext slots).
- events.js: dropped the .column-filter[data-filter] input wiring.
- tree.js: dropped recomputeVisibility() and the n.visible plumbing
in visibleIds() and updateCount(). Render is now a straight depth-
first walk over expanded subtrees; the count is just total rows.
setFilter is removed from the public API.
- css/tree.css: dropped .filter-row*, .filter-row__icon, and the
browse-local .column-filter rules (.column-filter is also defined
in shared/base.css for tools that still use it; that stays).
No test changes — tests/browse.spec.js never exercised filters.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
party/{received,issued}
The synthetic test fixture and many real deployments use PascalCase
folder names (Archive/, PartyB/, Received/, Issued/, Staging/). The
aggregator was hard-coding lowercase joins, which on case-sensitive
filesystems (Linux ext4) meant os.ReadDir returned NotExist and the
listing was empty even when the data was present.
Use zddc.ResolveCanonical to find the on-disk casing for each
canonical segment (archive/, staging/, then per-party received/ and
issued/), and emit URLs with the resolved casing so the dispatcher's
URL canonicalisation is a no-op pass-through.
The case-insensitive lookup was already used elsewhere (file API's
mkdir, tree.go's virtualUserHomeEntry); reviewing/ now matches that
convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the reviewing/ aggregator described in the saved
project memory (~/.claude/projects/-home-user-src-zddc/memory/
project_reviewing_folder_design.md). reviewing/ stays in
VirtualOnlyCanonicalNames — never materialised on disk — and is
served as a join over archive/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.
Two depths, both trailing-slash:
GET <project>/reviewing/?json=1
→ array of virtual <tracking>/ entries, one per submittal in
archive/<party>/received/ that doesn't yet have a matching
archive/<party>/issued/ entry. Sorted by tracking. URLs stay
under reviewing/ so the user can drill into the per-submittal
view. ACL: per-party, filtered like fs.ListDirectory.
GET <project>/reviewing/<tracking>/?json=1
→ array of two virtual entries, received/ + staged/, with
canonical URLs pointing back to archive/<party>/received/...
and staging/... respectively. staged/ is omitted when no
response draft exists yet.
When the response moves staging/ → archive/<party>/issued/, the
entry vanishes from depth-0 on the next listing. No mutation of
the reviewing/ subtree itself; pure join, recomputed on read.
Front-end at <project>/reviewing[/<tracking>/] is mdedit (per
user request). DefaultAppAt + AppAvailableAt extended to recognise
"reviewing" as a canonical mdedit-bearing folder. The polyfill in
shared/zddc-source.js is updated to follow listing entries' explicit
url field when present (absolute or root-relative) — that's how
mdedit's tree follows the depth-1 received/ + staged/ links into
the canonical archive/staging subtrees.
Dispatcher routing in zddc-server/main.go:
- GET <project>/reviewing/[<tracking>/] with Accept: json
→ ServeReviewing
- GET <project>/reviewing/[<tracking>/] with Accept: html
→ mdedit (rooted at the virtual path; polyfill fetches the
JSON listing on its own)
- GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
- GET <project>/reviewing/<tracking> (no slash) → 301 to slash form
Tests:
- handler/reviewinghandler_test.go (6 cases): IsReviewingPath
classification + ServeReviewing depth-0/depth-1 with and without
staged drafts + 404 on unknown tracking + empty when archive/ is
absent.
- apps/availability_test.go updated: reviewing/ now expects mdedit
rather than "" (no default).
- cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders
extended to assert reviewing → mdedit at the no-slash form;
older "no-slash/reviewing → 301" test removed.
Future work (not in this commit): write translation. Editing a file
under reviewing/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.md before
fetching — the user's URL bar moves to the canonical path on click.
A virtual-filesystem mode where the URL bar stays under reviewing/
throughout would require server-side write rewriting (translate
PUT/DELETE on reviewing/.../staged/... into the canonical staging/
path). Not needed for the MVP — links in mdedit's tree work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
when missing on disk
Mirror of the existing IsDir-branch behavior at line 873
(<project>/working → mdedit, <project>/staging → transmittal,
<project>/archive → archive) for the case where the folder doesn't
exist on disk yet. Without this, GET <project>/working on a fresh
project 404s instead of opening mdedit rooted at the (virtual)
working directory.
Behavior matrix for canonical project-root folders that don't yet
exist on disk:
GET <project>/archive → archive tool (project-root mode)
GET <project>/archive/ → empty browse listing
GET <project>/working → mdedit rooted at working/
GET <project>/working/ → empty browse listing (with synthetic
<viewer-email>/ home entry)
GET <project>/staging → transmittal rooted at staging/
GET <project>/staging/ → empty browse listing
GET <project>/reviewing → 301 to /reviewing/ (no default app)
GET <project>/reviewing/ → empty browse listing
GET <project>/random → 404 (still — non-canonical)
GET <project>/random/ → 404 (still — non-canonical)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix in fs.ListDirectory was insufficient — main.go's
dispatcher calls os.Stat(absPath) before reaching ServeDirectory,
and 404s on the missing path before the listing code ever runs.
Symptom: GET <project>/working/ on a fresh project still returned
"Not Found" despite the read-side fallback being committed.
Add the same fallback at the dispatcher level: when os.Stat returns
NotExist AND the URL ends with "/" AND the path matches
IsProjectRootFolder, fall through to ServeDirectory rather than
404. ServeDirectory's ACL check + ListDirectory's empty-listing
behavior take it from there.
Separately, fs.ListDirectory now initializes its result slice to
make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`,
so the JSON encoder emits "[]" rather than "null" for empty
listings — clients (browse, archive) expect an array and choke on
null.
New test TestDispatchEmptyCanonicalProjectFolders covers the four
canonical names (archive/working/staging/reviewing) on a project
where none of them exist on disk yet, plus the negative case (a
non-canonical missing path still 404s).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expanding a folder that returns 404 used to throw "HTTP 404 fetching
…" through statusError, surfacing it as a red error toast. From the
user's POV, a missing or empty directory shouldn't be presented as a
load failure — empty IS the legitimate state.
fetchServerChildren now returns [] on 404; other non-2xx still throw.
Other failure modes (transport error, 500, malformed JSON) continue
to surface as before.
Server-side, zddc-server already returns 200 + [] for canonical
project folders that don't exist on disk (the prior commit). This
client fix covers the residual cases:
- non-canonical paths that don't exist (deleted between listing
and expand; race with concurrent writers)
- non-zddc-server backends (Caddy file_server, plain nginx) where
we can't change the 404 behavior
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing <project>/{archive,working,staging,reviewing}/ when the folder
doesn't exist on disk now returns an empty 200 listing instead of 404.
The stage-strip nav links into these folders unconditionally; without
this fallback, clicking "Working" against a fresh project (where
working/ hasn't been written to yet) lands on a 404 page rather than
a usable empty view.
Mechanism stays consistent with the existing lazy-folder design:
- GET on missing canonical folder → 200 + empty listing (this commit)
- first WRITE under the same path → EnsureCanonicalAncestors
materialises the on-disk folder + auto-own .zddc
reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the
fallback just makes its empty listing always renderable. The future
reviewing/ aggregator (recorded in project memory) will replace the
empty listing with the join-computed virtual entries.
The fallback is gated on IsProjectRootFolder — only depth-2 paths
matching one of the four canonical names. Non-canonical missing paths
still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound).
For working/ specifically the synthetic <viewer-email>/ home entry
still fires from virtualUserHomeEntry, so the user sees their own
placeholder even when working/ doesn't exist yet — first write into
that placeholder triggers the lazy-create chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beta cut of the eight HTML tools into zddc/internal/apps/embedded/*
and the unified form/tables bundle into zddc/internal/handler/tables.html.
Each tool's on-page label changes from alpha → beta-stamped bytes;
no source changes beyond the build label itself.
The dev image (Dockerfile, devshell, ZDDC_REF=main) and the bitnest
test container both pick this up automatically — bitnest's path-unit
fired on the rebuild of zddc/dist/zddc-server-linux-amd64 and
restarted the container with the new embedded apps:
embedded_apps=archive=v0.0.17-beta browse=v0.0.17-beta
classifier=v0.0.17-beta form=v0.0.17-beta
landing=v0.0.17-beta mdedit=v0.0.17-beta
tables=v0.0.17-beta transmittal=v0.0.17-beta
Source-side commits since the previous beta:
feat(landing): single-project click → <project>/archive.html
feat(shared): non-blocking toast helper
feat(shared): lateral project-stage strip
feat(form): standalone empty-state welcome
fix(tables): keepalive on beforeunload save path
refactor(mdedit): drop window.* TOC globals
refactor(archive): remove dead debounce
style(transmittal): tokenize utility classes, drop !important block
style: replace inline styles with CSS
test(shared): zddc-source.js + toast + nav specs
test(browse): smoke spec
docs: tool counts + state pattern + polyfill gaps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The build's embedded-commit step (beta + stable cuts) tried to
'git add zddc/internal/handler/form.html' alongside tables.html,
but that path doesn't exist — form.html was never an //go:embed
target. Form-mode renders through the unified tables.html bundle
via the zddcMode dispatcher; there's no standalone embedded form
HTML on the server side.
The line was a leftover from before form and tables were unified.
A beta cut against current main hits 'fatal: pathspec ... did not
match any files' and aborts before the embedded changes are
committed, leaving them dangling in the working tree.
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>