Three small helpers under window.zddc.cap, wired into every tool's
build:
cap.at(path) — Promise<AccessView|null>. Fetches
/.profile/access?path=<urlpath> and
memoises per-path for the session.
Used by tools to gate top-of-page
affordances on path_verbs / path_is_admin
/ path_can_elevate_grant.
cap.has(node, verb) — boolean. Reads the listing entry's
verbs string for the named verb.
Falls back to node.writable for 'w'
when verbs is absent (offline FS-API
listings or pre-promotion clients).
cap.handleForbidden(resp, — parses a 403 response's JSON body for
opts) missing_verb and renders an error
toast. When opts.path is supplied AND
the path-scoped access view reports
path_can_elevate_grant covering the
missing verb, the toast appends an
"Elevate" button that flips the
elevation cookie and reloads.
Browse loader.js + tree.js carry the new verbs field through to the
node objects so context-menu gating can call cap.has(node, 'w'|'d')
without changing the legacy node.writable contract. New CSS rule
.zddc-toast__action styles the inline Elevate button.
Concatenation order: cap.js comes after toast.js + elevation.js so
the dependencies (window.zddc.toast, window.zddc.elevation) are
present at module-load time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing JSON gains a writable bool per file row, computed by running
the policy decider with ActionWrite against the parent-dir chain
(with the same admin-bypass branch the file API uses). Cost: one
extra decider call per file in the listing, sharing the parent
chain so the cascade walk is amortized.
Browse loader stores writable on every tree node. The markdown and
YAML editors read it and gate their canSave + initial mount:
- !writable markdown → Toast UI Viewer (rendered, no edit toolbar,
no caret). Banner above explains why save is disabled.
- !writable YAML → CodeMirror readOnly:'nocursor' (selection for
copy, no caret). Banner above explains why save is disabled.
Both editors gain autofocus:false so keyboard nav in the browse
tree doesn't divert into the editor — arrow keys keep moving through
files and folders without the caret jumping. User clicks (or tabs)
into the editor when they actually want to type.
.zddc files already route through preview-yaml's isZddcFile path;
bare .zddc (no ext) matches because that function checks the
literal name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two layers shipped together since the second builds on the first.
LAYER 1 — reviewing/ + Plan Review scaffolding
- reviewing/ is now a real folder under each project, populated by the
Plan Review composite endpoint. The old reviewing/ virtual aggregator
handler is retired.
- POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op:
plan-review scaffolds physical workflow folders under reviewing_root
and staging_root, each carrying .zddc.received_path pointing back at
the canonical submittal. Idempotent re-runs match by received_path
and re-converge the ACL.
- Virtual received window: when listing or writing under
<workflow>/received/, the server resolves through the canonical
archive/<party>/received/<tracking>/ via the workflow's
.zddc.received_path. Writes get rewritten to
<workflow>/<base>+C<n><suffix> so review comments land in the
workflow folder and never touch the WORM archive.
- Cascade defaults declare on_plan_review per project so the
reviewing_root and staging_root are configurable.
LAYER 2 — browse context-menu workflows
- Accept Transmittal: right-click a transmittal folder in
archive/<party>/incoming/ → validates ZDDC folder + filename
conformance, atomic-renames the folder to
archive/<party>/received/<tracking>/ (WORM zone), and optionally
chains into Plan Review in the same composite request. Re-acceptance
with a different revision merges file-by-file; WORM forbids
overwrite of an existing filename.
- Stage / Unstage: right-click files in working/<…>/ → "Stage to…"
with picker of existing staging transmittal folders + inline
"New transmittal folder…" create; right-click files in
staging/<…>/ → "Unstage to working/" defaulting to the user's
working/<email>/ home. Reuses the file-API move primitive.
- Create Transmittal folder: right-click the staging/ pane → prompts
for a ZDDC-conforming folder name with live validation; mkdir,
then navigate to the new folder URL where the transmittal tool
serves the editor.
- Supporting infrastructure: new CanonicalFolderAt cascade lookup +
X-ZDDC-Canonical-Folder response header so the browse SPA can
scope-gate menu items without re-implementing the cascade
client-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.
┌─ browse toolbar ─────────────────────────────────────────────┐
│ Sort: [Name (A→Z) ▾] ☐ Show hidden │
└──────────────────────────────────────────────────────────────┘
Server-side surface:
- internal/fs/tree.go ListDirectory gains an `includeHidden bool`
parameter. The .-prefix filter (previously hard-coded) now also
drops _-prefix entries (matches dispatch's reserved-prefix guard)
and honors the new flag.
- internal/handler/directory.go reads `?hidden=1` from the request
and threads it through.
- cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
_-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
a hidden entry's link works. `_app/` (apps cache) stays
unconditionally reserved — those bytes must go through the apps
resolver. Writes to hidden paths stay blocked (the file API has
its own segment check that the flag does NOT relax).
- internal/listing/listing.go: signature parity (the lower-level
helper that's used by tests + non-cascade listing paths).
Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:
• <dir>/.zddc ACL YAML — already exposed via /.profile/zddc
• <dir>/.converted/<base> cached MD→DOCX/HTML/PDF, same sensitivity as source
• <root>/.zddc.d/tokens/ per-token metadata; filename = sha256(token)
so not bearer-usable. Default root ACL
restricts to admins; matches /.tokens UI.
• <root>/.zddc.d/logs/ access logs; same admins-only audience
• <root>/_app/ cached upstream tool HTML (public)
• <root>/_template/ install.zip scaffolding (public)
None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
The /incoming/ path regex in browse/js/grid.js was the second-most
visible client-side hardcode of the canonical convention. Migrating
it to the cascade:
Header surface:
X-ZDDC-Default-Tool: <name> The cascade-resolved default tool
for the listing's directory. Empty
header = no default declared.
Client wiring:
loader.fetchServerChildren reads the header into
state.scopeDefaultTool on every listing fetch (initial mount,
rescope on dblclick, popstate). grid.classifierAvailableHere
now returns scopeDefaultTool === 'classifier' instead of
regex-matching the URL.
Effect:
Grid mode auto-activates wherever the cascade picks classifier
as the default — currently archive/<party>/incoming per
defaults.zddc.yaml. An operator who sets default_tool: classifier
on a custom directory gets grid mode there too, no code change.
An operator who removes the default at incoming sees grid mode
stop auto-activating there.
Bootstrap timing fix:
The initial events.init() runs applyResolvedViewMode before the
detection fetch completes, so state.scopeDefaultTool is empty
at that point and grid never auto-activates on first paint.
app.js bootstrap now re-applies the resolved view mode after
autoDetectServerMode returns, so a fresh /incoming URL lands
on grid mode immediately.
The /incoming/ regex is gone. Two client hardcodes remaining
(archive source heuristics, shared/nav stage strip) — Phase 4c/d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The last hardcoded client-side knowledge of the canonical convention
was the upload-zone regex in browse:
var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;
Now declared in the cascade:
Schema:
drop_target: true|false leaf-only; describes THIS dir
(not propagated to descendants)
Lookup:
zddc.DropTargetAt(root, dir) bool
Surfaced to clients:
Directory listings carry an X-ZDDC-Drop-Target: true response
header when the cascade declares this leaf as an upload zone.
No header = no drop target.
Defaults populated:
working / working/* / staging / archive/<party>/incoming
all carry drop_target: true. Operators can extend (e.g. drop
files on archive/<party>/received via override) or disable
(e.g. drop_target: false at a specific staging subtree) without
touching code.
Browse migration:
loader.fetchServerChildren reads the response header and stamps
state.scopeDropTarget on every listing fetch. upload.js's
currentScopeAllows now reads that flag instead of regex-
matching the URL. Initial value is false in init.js so a
listing failure (offline / server doesn't emit the header)
safely defaults to "no drop zone".
Phase 4a closes the most visible asymmetry between server-side and
client-side cascade knowledge. The remaining client hardcodes
(browse grid-mode regex, archive source heuristics, shared/nav
stage strip) follow the same pattern when needed — Phase 4b/c/d.
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>
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>
- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
the browse build instead of CDN-loading. Eliminates the failure
mode where ZIP rows can't expand because the CDN script doesn't
load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
spreadsheet-style auto-filter rows in <thead>:
- 📄 row: file-name filter + extension filter
- 📁 row: folder-name filter
Each input uses shared/zddc-filter syntax (substring/!negate/
^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
- file matches keep their ancestor folders visible (path-to-hit)
- folder match keeps its descendants visible
- filters compose (file ∧ folder ∧ ext) so combinations narrow
Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
that tints with currentColor.
Bundles Phase 2 polish + the user-requested header/breadcrumb work:
- Breadcrumbs replacing the plain currentPath span. Server mode
renders linkified ancestor segments (each <a> navigates to that
directory; the browser fetches browse.html, the new instance
auto-loads the listing). FS-API mode renders the rootHandle name
as a non-link (no ancestor handles to navigate). Both prefix the
path with a 🏠 root icon. Trailing slash + bold-current segment
match common file-explorer conventions.
- Subdued 'Select Directory' button in server mode. Once browse is
serving a real directory listing, the local-folder switcher is
available but visually quiet (btn--subtle: transparent, muted
color). FS-API mode keeps the primary styling (it's how the user
got there). New btn--subtle CSS class added to browse's tree.css.
A refresh button (⟳) appears next to it in both modes; clicking
it re-fetches the current root listing.
- Header consistency: browse now matches archive's header layout
(refresh + help buttons in addition to theme on the right). Help
is a placeholder for future help dialog wiring.
- File preview popup. Click a file row → opens a popup window with
the file rendered. Plain types (PDF, HTML, image) load in
iframes; TIFF + ZIP listings via shared/preview-lib.js's
renderTiff / renderZipListing helpers; text via <pre>; unknown
types → 'click Download' placeholder. Modifier-click (ctrl/cmd/
shift) and middle-click still open the file in a new tab via the
underlying <a target=_blank>. Single popup window is reused
across multiple file clicks (matches archive's UX).
- ZIP inline expansion. .zip files have a chevron and act like
folders in the tree. First expand fetches the zip bytes
(server URL or FS handle or parent-zip read), parses with JSZip
(auto-loaded from CDN), and synthesizes the entry tree. Nested
directories within the zip lazy-expand on demand by re-walking
the cached entry list at the right path prefix. Click on a
zip-entry file opens the preview popup with bytes read from
JSZip. Recursive expand-all skips zip archives by design — they
can be very large, and explicit click-to-expand is safer.
- Extension multi-select filter. Toolbar now has a <select
multiple> populated with extensions present in the current
view. Filter is OR-of-selected; combined with the name filter
it's AND-of-both. Folders pass through (so expanding a folder
whose name doesn't match the ext filter still shows its file
children that do match).
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.
Modes (auto-detected at page load):
- Online: when served by zddc-server at a folder URL, queries
the same URL with Accept: application/json to load the listing
and renders it. Auto-served as the default at any directory
under ZDDC_ROOT without an index.html (replacing the previous
minimal-HTML stub from directory.go).
- Local: 'Select Directory' button uses FileSystemAccessAPI to
pick any folder on disk; works in Chromium-based browsers.
Features (Phase 1 — what's in this commit):
- Tree view with lazy-loaded folders (children fetched on first
expand).
- Sort by name / size / extension / date (column header click).
- Filter by name substring (toolbar input).
- File click opens in a new tab — for server-backed pages,
routes through zddc-server's normal handler so .archive
redirects + apps cascade overrides + ACL all apply.
Phase 2 deferred:
- ZIP files inline expansion (treat archive entries as virtual
children).
- File preview popup (reuse shared/preview-lib.js).
- Extension multi-select filter.
Wiring:
- browse/ added to top-level ./build's per-tool list, embed
block, versions.txt, and the lockstep release commit + tag set.
All seven tools (archive, transmittal, classifier, mdedit,
landing, form, browse) advance together on stable cuts.
- shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
verify_channel_links's per-tool loop.
- zddc/internal/apps/embed.go: //go:embed browse.html +
EmbeddedBytes("browse") case.
- zddc/internal/apps/availability.go: browse available at every
directory (same as archive).
- zddc/internal/apps/handler.go: MatchAppHTML routes
/<dir>/browse.html → 'browse'.
- zddc/internal/handler/directory.go: when a directory request
arrives with Accept: text/html and no index.html exists,
serve the embedded browse.html bytes (with a JSON-fallback
if the embedded slot is empty during bootstrap).