The HTML→PDF stage failed with:
Creating shared memory in /dev/shm/.org.chromium.Chromium.XXXXXX
failed: Read-only file system (30)
Unable to access(W_OK|X_OK) /dev/shm: Read-only file system (30)
Chromium tries to put its IPC shared-memory segments under /dev/shm
by default. Our container runs --read-only with /dev/shm inherited
from the image (which makes it read-only too). The well-known fix is
the --disable-dev-shm-usage chromium flag, which routes those
allocations to /tmp instead.
/tmp is a writable tmpfs we already set up. Bump its size from
128 MiB to 256 MiB so chromium has room for both its user-data-dir
and the redirected shared-memory segments. A small PDF flow used
~64 MiB free of 128 MiB available; doubling gives headroom without
materially changing the pod's memory footprint (tmpfs only consumes
RAM for bytes actually written).
The discardable_shared_memory_manager warning ("Less than 64MB of
free space in temporary directory") in the prior chromium log was a
symptom of this same /tmp-too-small condition; the bump quiets it
too.
Other warnings in the log (dbus connect failures) are not load-
bearing — chromium falls back gracefully when dbus is absent. No fix
needed there.
zddc-server can now invoke podman as a CLIENT against a remote socket
instead of creating containers in its own process. The sidecar pattern
in tnd-zddc-chart will use this so zddc-server's own pod stays
unprivileged (only the podman-system-service sidecar runs privileged).
New surface:
--convert-podman-socket / ZDDC_CONVERT_PODMAN_SOCKET
e.g. unix:///var/run/podman/podman.sock
Empty (default) → local mode (podman creates containers in
zddc-server's own filesystem namespace).
Non-empty → remote mode: `podman --remote --url=<this> run …`
dispatches each container request to whatever process owns the
socket. Typically a `podman system service` sidecar in the same
Kubernetes pod.
--convert-scratch-dir / ZDDC_CONVERT_SCRATCH_DIR
Host-side directory for per-conversion intermediates (template,
HTML, PDF). In remote mode this MUST be a path the sidecar sees
at the same mountpoint — typically a shared emptyDir at /work
in both containers. Empty = $TMPDIR (local-mode default).
Runner behaviour:
local mode → unchanged. `podman run --userns=host --rm --pull=missing
--network=none --read-only …`. `--userns=host` stays so nested-podman
on a privileged host (the previous chart shape) keeps working for
anyone still using it.
remote mode → `podman --remote --url=<sock> run --rm --pull=missing
--network=none --read-only …`. `--userns=host` is dropped because
the sidecar is rootful inside its own privileged container and
doesn't need userns juggling.
Health probe gains a Mode field ("local" | "remote") and, in remote
mode, runs `podman --remote --url=<sock> version` to confirm the
sidecar's socket is reachable. Unreachable-socket → 503 with a clear
reason (sidecar may still be starting up); reachable → ready.
Capabilities log now includes engine_version + mode + remote_url for
easier debugging of "which podman is actually doing the work".
No tests removed — the existing fake-runner table covers both modes
since the runner's args are uniform (remote prefix is the only thing
that differs).
When zddc-server runs inside a Kubernetes pod and shells out to
`podman run`, the inner podman tries to set up its own user namespace
via /usr/bin/newuidmap. The mapping fails inside the pod's namespace
even with privileged: true:
newuidmap: write to uid_map failed: Invalid argument
Error: cannot set up namespace using "/usr/bin/newuidmap": exit status 1
Adding --userns=host to the inner `podman run` tells it to reuse the
caller's user namespace instead of creating a new one — newuidmap
isn't invoked. The chart already runs the pod privileged so reusing
its userns adds no new privilege; --cap-drop=ALL + --network=none +
--read-only + --tmpfs continue to isolate the inner container.
On a bare-metal host invocation, --userns=host means "no userns
remapping at all", which is the default for rootful podman and works
identically to the prior behavior — the bitnest test setup and any
laptop dev runs are unaffected.
Smoke-tested locally with the exact flag set: pandoc/latex:latest in
a --userns=host --read-only container produces valid HTML from
`# Hello world` on stdin.
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.
Flip default_tool from `mdedit` to `browse` (which now ships a Toast UI
markdown editor plugin in its preview pane) at:
• paths."*".paths.working
• paths."*".paths.working.paths."*" (per-user homes)
• paths."*".paths.reviewing
available_tools at those levels drops `mdedit` and adds `browse` next
to `classifier`. Operator overrides per .zddc cascade still work; only
the embedded baseline changes.
Test fixtures updated:
• lookups_test.go — DefaultToolAt assertions for working/+reviewing/
• availability_test.go — AppAvailableAt + DefaultAppAt for working/+
reviewing/+per-user home
• main_test.go — dispatch route asserts "ZDDC Browse" (was "ZDDC
Markdown"); Apps cascade fixture swaps mdedit
for browse so the live route fetches the right
embedded HTML
New endpoint GET /<path>/foo.md?convert=docx|html|pdf renders a markdown
source on demand. Surfaced as the Download buttons in browse's markdown
editor (separate commit).
Execution model — two upstream container images, lazy-pulled:
• docker.io/pandoc/latex:latest — MD→DOCX, MD→HTML (entrypoint pandoc)
• docker.io/zenika/alpine-chrome — HTML→PDF (entrypoint chromium-browser)
No custom image build. The runner passes --pull=missing on every podman/
docker invocation so the operator only needs the runtime installed —
first request pulls the image, subsequent requests use the local cache.
Overrides: --convert-pandoc-image / --convert-chromium-image (and the
matching ZDDC_CONVERT_* env vars). Engine: --convert-engine (podman
preferred, docker fallback). Resource caps: --convert-mem-mib (512),
--convert-cpus (2), --convert-pids (100), --convert-timeout (30s).
PDF flow is two-stage: pandoc renders the markdown through the embedded
viewer-template.html to standalone HTML, then chromium prints that HTML
via --print-to-pdf. Preserves the print-media CSS already authored in
viewer-template.html rather than going through pandoc's LaTeX template.
Each conversion runs in a throw-away container with --rm --network=none
--read-only --tmpfs=/tmp --cap-drop=ALL --security-opt=no-new-privileges
--env=HOME=/tmp plus a bind-mounted scratch dir for I/O. Pandoc reads
markdown from stdin / writes to stdout; the viewer template lives at
/tpl (ro). Chromium reads HTML from a read-write bind mount at /pdf
and writes the PDF to the same mount; the host reads it back. No shell
wrappers, no shell quoting — argv flows straight into each image's
entrypoint.
On-disk cache at <dir>/.converted/<base>.<ext> with mtime synced to the
source. Fast path is a stat-and-serve with no exec; slow path
singleflights concurrent requests for the same target. PUT/DELETE/MOVE
on the source .md purges the .converted/ sidecars.
Per-project template variables (client/project/contractor/project_number)
come from a new .zddc `convert:` cascade block, walked leaf→root with
per-key latest-wins. Filename-derived variables (title, tracking_number,
revision, status, is_draft) come from a new zddc.ParseFilename helper.
If neither podman nor docker is on PATH, the endpoint serves 503 with
a clear Retry-After. The rest of the server keeps working.
This is the first os/exec site in the codebase. The hardening in
internal/convert/runner.go — context.CancelFunc → process kill,
cmd.WaitDelay, platform-specific SysProcAttr (Setpgid + Pdeathsig on
Linux), minimal env, stdout cap via limitWriter, stderr ring buffer —
sets the pattern for any future shell-outs.
Public surface:
convert.ToDocx(ctx, source, meta) / .ToHTML / .ToPDF
convert.Probe(ctx, engineOverride) → install Runner if engine present
convert.SetImages(pandoc, chromium)
convert.ConfigureLimits(memMiB, cpus, pids, timeout)
convert.Available()
Container handler at internal/handler/converthandler.go; dispatcher
branch in cmd/zddc-server/main.go inserts the convert lookup after the
existing ACL gate, reusing the source file's read policy verbatim.
zddc-server can now hand back a whole directory subtree as a single
streamed application/zip download: GET /some/dir/?zip=1 (works on both
/dir and /dir/) → Content-Type: application/zip + Content-Disposition:
attachment; filename="<dir>.zip", containing every readable file under
/some/dir/, recursively.
handler.ServeSubtreeZip walks the tree with filepath.WalkDir, ACL-gates
each file by the .zddc chain of its containing directory (per-dir
decision cache, same shape as serveArchiveListing), skips hidden
entries ("." and "_" prefixes — .zddc, _template, _app), and adds a
.zip *file* it encounters as opaque bytes (it does not recurse into it
— that's the navigable-virtual-surface feature, a different thing).
The response is streamed (zip.NewWriter straight onto the
ResponseWriter, Store for already-compressed extensions, Deflate
otherwise), so a fully-ACL-denied or empty subtree just yields a valid
empty zip rather than a 403 (a stream can't change status after the
headers go out; empty leaks no more than 403). HEAD sends the headers
and no body. The dispatch's directory ACL gate still runs first, so a
viewer who can't read the directory gets 403 before the handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zddc-server can now browse into a .zip file without the client
downloading the whole archive:
- GET …/Foo.zip/ → JSON listing of the zip's members
(Accept: application/json), or the
browse SPA (HTML) — same content
negotiation as ServeDirectory/.archive
- GET …/Foo.zip/sub/doc.pdf → extracts and streams that one member
(Range / ETag / conditional GET via
http.ServeContent)
- GET …/Foo.zip → unchanged: the raw .zip download
- PUT/DELETE/POST …/Foo.zip/… → 405 (zip access is read-only)
New internal/zipfs package reconstructs directory levels from the zip's
flat central directory (synthesising intermediate dirs with no explicit
"<dir>/" entry, mirroring what browse does client-side with JSZip) and
drops zip-slip-unsafe entries ("..", absolute, backslash). New
handler.ServeZip wraps it. The dispatcher gets splitZipPath + an
intercept placed before the file-API branch (so a write to a path under
a .zip is refused, not silently mkdir'd); ACL is the chain of the
directory CONTAINING the zip — a zip carries no .zddc of its own, same
as the .archive virtual surface. The os.Stat-per-segment walk is gated
by a cheap ".zip/" substring check so ordinary requests are unaffected.
Also fixes two pre-existing dispatch-test failures uncovered along the
way: a non-existent top-level "*.html" URL was 302'ing to its slash
form (because the bare "*" project glob makes every first-level segment
"declared") — the cascade-declared no-slash block now requires a
directory-shaped URL (trailing slash, or no file extension); and the
stale TestDispatchSlashRouting expectation that archive/<party>/mdl/
302s to mdl/table.html was updated to match the intended behaviour
(the default-MDL virtual fallback shows the browse listing there; only
a real on-disk tables: + *.table.yaml triggers the bounce).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The trailing-slash directory form was hardcoded to serve `browse`. Add a
`dir_tool` .zddc key (cascades leaf→root, floors at `browse`) so an
operator can point a subtree's slash form at another directory-oriented
tool — the symmetric counterpart to `default_tool` (the no-slash
"specialized app"). handler.ServeDirectory now resolves it via
zddc.DirToolAt; JSON listing requests are unaffected (raw listing
always served, so browse can still enumerate).
Also collapse the no-slash dispatch: the on-disk-directory and the
virtual-declared-path branches in main.go each carried their own copy
of "default_tool → tables-carveout-or-apps.Serve → 302", with
inconsistent ACL checks. Extract one chokepoint, serveSpecializedNoSlash,
that enforces ACL uniformly for every default_tool route.
Updates ARCHITECTURE.md and AGENTS.md: the stale "Special folders" /
hardcoded-availability sections now describe the .zddc-cascade model
(defaults.zddc.yaml, the schema-key table, the slash/no-slash
convention, WORM, standard roles).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file API's mkdir post-hook still seeded auto-own .zddc files via the
hardcoded IsAutoOwnPath path-segment predicate, while
EnsureCanonicalAncestors had already moved to the cascade's auto_own:
flag. Point the hook at AutoOwnAt / AutoOwnFencedAt so both paths agree
and an operator's .zddc reshaping actually takes effect — fenced when
the new directory's own cascade level declares auto_own_fenced (per-user
working homes), unfenced otherwise.
Retires IsAutoOwnPath and WormMask (the latter already superseded by
WormZoneGrant's & VerbsRC) plus their tests, and the now-unused
path/filepath import in special.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clarify the incoming/ semantics per the workflow: it's the
counterparty's drop zone, not a free-for-all.
- project_team gets read only here (inherited from the project
level — they have no c/w, so they can see what's been dropped
but not touch it). No change in effect; documented explicitly.
- document_controller gets rwcd here (restated at the incoming/
cascade level). The QC + transfer workflow — classifier renames
files in place (w), then they move to received/ (delete here +
worm-create there) — needs the delete bit, which the inherited
project-level `rw` lacked.
- The counterparty's uploader still gets access via a deployment
.zddc (acl: { permissions: { "*@acme.com": cr } } at
archive/Acme/incoming/.zddc) or by mkdir'ing a dated subfolder
under incoming/ and owning it via the existing auto_own — both
flows unchanged.
Test: standardroles_test now asserts the doc controller has rwcd at
incoming/ and a project_team member has only r there.
All Go + Playwright tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Answers "can roles reset as well as add?" — yes, both now.
Role membership UNIONS across the cascade:
- A deeper .zddc that defines an inherited role again with one
extra member ADDS that member (was: deepest definition shadowed
the ancestor's entirely).
- New `reset: true` on a role definition breaks the union — that
level's members are authoritative, ancestor definitions above
are excluded; descendants below still union on top. Use it to
give a project its own team independent of a deployment-wide
default.
- lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
union members, stop at the first reset:true; finally fold in
chain.Embedded.Roles as the baseline so a role declared only in
defaults.zddc.yaml is "defined" (and a deployment's on-disk
redefinition unions on top).
Admin checks are now role-aware:
- IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
MatchesPrincipal instead of MatchesPattern, so `admins:
[document_controller]` resolves to the role's members. The
strict-ancestor scan resolves roles only up to level i, so a
role defined at the deepest level (= dirPath) never confers
self-edit rights.
Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):
document_controller — files into the WORM zones. Gets:
- rw at the project level (read + overwrite-existing; NOT c, so
it can't make arbitrary folders)
- rwc at archive/ (can create party subfolders)
- subtree-admin at working/ and staging/ (full create + manage,
including taking over a fenced per-user home) — scoped HERE,
not at the project root, so the WORM constraint still binds
it in archive/<party>/received|issued
- listed in worm: on received/ and issued/ → write-once-create
survives the WORM mask
project_team — read-only across the project. The per-user
working home's fenced auto-own .zddc (rwcda for the creator)
wins via deepest-match, so "read-only except what I own" falls
out of the cascade with no special rule. Inside received/issued
their r is preserved (worm: doesn't strip read).
archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.
Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per design feedback: the verb string in a worm: entry was always
effectively "cr" (the key's whole job is to restore write-once-create
inside the locked zone, and you need read to see what you filed), so
spelling it out per-entry was redundant. worm: is now just a list of
principal patterns — email-globs, @role:name, or bare role names —
and every listed principal gets read + write-once-create. An empty
list ([]) still marks the WORM zone with no create-capable
principals.
Changes:
- ZddcFile.Worm: map[string]string → []string
- mergeOverlay: concat-dedupe (a deeper .zddc adds controllers);
mergeStringSlicePreserveEmpty keeps `worm: []` non-nil through
the overlay so it still marks the zone
- WormZoneGrant: walks the list, grants VerbsRC to each matching
principal; result is always ⊆ {r, c}
- ValidateFile: validates each entry as an email-glob (role refs
skipped — validated by the role machinery)
- defaults.zddc.yaml: received/ and issued/ carry `worm: []`
- tests updated to the list form (worm_test.go, fileapi_test.go)
All Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WORM (write-once-read-many) is no longer a special folder type keyed
off the literal names "received"/"issued". It's a cascade key —
`worm:` on any directory's .zddc — with the ACL-shaped semantics the
user described.
Schema:
worm:
"doc-control@example.com": cr # email-glob or @role:name → verbs ⊆ {r, c}
# an empty map ({}) is a WORM zone with no create-capable principals
Effect inside a WORM zone (any cascade level declares worm:), applied
AFTER the normal cascade ACL and BEFORE the admin escape hatch:
- w / d / a stripped for everyone
- c survives only via the worm: map
- r survives via the normal ACL OR the worm: map (so a document
controller who isn't in the project ACL still gets read+create)
- worm: grants UNION across the cascade — deeper .zddc can name
more controllers
- admins (root / subtree) bypass entirely — handler does the
IsAdmin check before the policy evaluator
defaults.zddc.yaml: archive/<party>/received and archive/<party>/issued
carry `worm: {}` (WORM zone, no controllers — the deployment names
its document controller by adding a deeper .zddc with
`worm: {<principal>: cr}`). The canonical convention is unchanged;
the difference is an operator can now mark any directory WORM, or
rename received/issued, without a code change.
Removed (hardcoded path predicates, superseded by the cascade walk):
zddc.IsWormPath
zddc.WormFolderLevelIndex
zddc.splitPathSegments (only IsWormPath used it)
Kept: zddc.WormMask (generic verb-set primitive), zddc.VerbsRC.
New:
zddc.WormZoneGrant(chain, email, mode) → (verbs, inWormZone)
Walks the chain for worm: declarations; unions the principal's
grants masked to {r, c}.
policy.InternalDecider.Allow: WORM block rewritten to consult
WormZoneGrant instead of IsWormPath/WormFolderLevelIndex.
ValidateFile: worm: keys validated as email-glob (or @role:name);
values validated as verb strings ⊆ {r, c}.
Tests:
- new worm_test.go covers the embedded convention, operator-granted
controller, w/d masking, cross-cascade union.
- special_test.go's TestIsWormPath / TestWormFolderLevelIndex
retired; TestWormMaskStripsWDA kept.
- fileapi_test.go's WORM tests updated: the doc-controller grant is
now `worm: { _doc_controller: cr }` at issued/.zddc, not
`acl.permissions: { _doc_controller: cr }`.
- federal-parity and admin-bypass tests unchanged — the WORM mask
still strips w/d/a and admins still bypass.
All Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared/nav.js stage strip previously hardcoded four stages
(archive/working/staging/reviewing) with their labels and target
URLs baked into the file. Operators couldn't add a fifth stage or
rename "Working" to "In-Progress" without forking shared code.
Now cascade-driven end-to-end:
Server-side:
listing.FileInfo gains a Declared bool field. fs.ListDirectory
stamps Declared=true on every entry whose name matches the
cascade's ChildrenDeclaredAt(parent) — both real on-disk dirs
and virtual canonical injections. Bugfix in the same patch:
virtualCanonicalFolders was passing the relative dirPath to
ChildrenDeclaredAt (which expects absolute); now passes absDir.
Client-side:
shared/nav.js fetches the project root's JSON listing on
DOMContentLoaded, filters to declared+is_dir entries, sorts by
canonical workflow order (archive → working → staging →
reviewing, then any extras alphabetically), and renders the
strip. Labels read e.display_name → falls back to titleCase(name).
Hardcoded FALLBACK_STAGES kicks in only on fetch failure
(offline / file:// / non-zddc-server backend). Rendered
immediately so the strip appears without flicker, then the
cascade-fetched list replaces it once available.
Effect:
Project-3 (which has display: { archive: "Records",
working: "In-Progress", ... } in its .zddc) now shows
"Records · In-Progress · Outbox · Pending Responses" in every
tool's strip. Project-1 still shows "Archive · Working ·
Staging · Reviewing". No code change to render either; the
cascade decides.
Tests:
- tests/nav.spec.js relies on the mock server returning HTML at
every URL, so the fetch fails over to fallback stages — the
test renders the same Archive/Working/Staging/Reviewing labels
it always did, with no test changes needed.
- All 248 Playwright + all Go tests green.
Remaining client-side hardcode: archive/js/source.js +
archive/js/app.js's mode detection. Phase 4d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase 3 retired these symbols by migrating their consumers to the
.zddc cascade lookups. Removing them now that nothing references
them:
- var zddc.ProjectRootFolders
- var zddc.PartyFolders
- var zddc.AutoOwnCanonicalNames
- var zddc.VirtualOnlyCanonicalNames
- func zddc.IsProjectRootFolder
- func zddc.IsArchivePartyFolder
- func zddc.IsArchivePartyMdlDir
- func handler.isArchivePartyDir
The canonical convention is expressed in defaults.zddc.yaml and
consulted via lookups.go's DefaultToolAt / AutoOwnAt / VirtualAt /
IsDeclaredPath / ChildrenDeclaredAt / AvailableToolsAt /
IsToolAvailableAt. Operators override per-directory via on-disk
.zddc files; the embedded layer is the documented baseline.
Test removals:
- TestCanonicalLists (lists no longer exist)
- TestIsProjectRootFolder (function no longer exists)
Equivalent coverage lives in lookups_test.go's
TestDefaultToolAt_FromEmbeddedConvention,
TestIsDeclaredPath_FromEmbeddedConvention, etc. — which assert the
convention via the cascade's actual lookup path rather than the
predicates' return values.
handler.isAtArchivePartyMdlDir is RETAINED — it's still actively
consumed by RecognizeTableRequest's default-MDL fallback in
table.html URL resolution. That's a tighter file-path predicate
than the cascade walker would naturally express; can revisit if it
ever needs to become configurable.
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>
Two pieces:
1. Lookup helpers walk chain.Levels from leaf back to root. The
"parent applies to descendants unless overridden" cascade rule
means a working/ default_tool=mdedit propagates to deep paths
like working/alice@example.com/notes/sub/deep without anyone
declaring it at every level. AutoOwnAt and VirtualAt follow the
same walk; explicit false at a descendant can override an
ancestor's true (*bool semantics).
2. apps.DefaultAppAt delegates to zddc.DefaultToolAt. The hardcoded
switch on parts[1] (archive→archive, staging→transmittal,
working→mdedit, reviewing→mdedit, mdl→tables) and its case-
sensitivity quirks now live in defaults.zddc.yaml. Operators can
override any of these per-directory with an on-disk .zddc; no
code change required.
Semantic improvement: archive/<party>/incoming previously defaulted
to "archive" (because parts[1]=archive and the switch didn't look
deeper). The new convention routes it to "classifier" — incoming/ is
the bulk-rename surface, not a record browser. Updated
availability_test.go to reflect.
All other DefaultAppAt cases — including case-fold (Archive/MDL),
mdl override, reviewing virtual, project root returning "", random
non-canonical names returning "" — produce bit-identical output.
Two new tests in lookups_test.go cover the propagation:
- TestDefaultToolAt_PropagatesToDescendants
- TestAutoOwnAt_DescendantCanDisable
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema:
- default_tool: string (tool name served at this dir's no-slash URL)
- auto_own: *bool (mkdir post-hook auto-grants the creator)
- virtual: *bool (never materialise on disk; aggregator routes)
defaults.zddc.yaml: populated with the full canonical convention via
paths:. Top-level "*" matches any project; nested archive/working/
staging/reviewing declare the project-stage tools; archive's "*" /
mdl|incoming|received|issued tree declares the per-party surfaces.
All four party folders and all four project-root folders get their
default_tool; working / staging / archive/<party>/incoming get
auto_own; reviewing / archive/<party>/mdl get virtual. None of these
need on-disk dirs to exist.
Lookups (zddc/internal/zddc/lookups.go):
DefaultToolAt(root, dir) → cascade-resolved default tool name
AutoOwnAt(root, dir) → does mkdir auto-own here?
VirtualAt(root, dir) → never materialise on disk?
IsDeclaredPath(root, dir) → does the cascade say anything about this dir?
ChildrenDeclaredAt(root, dir)→ literal child names declared by Paths
Each looks up via EffectivePolicy → leaf level → Embedded fallback,
so operators' on-disk overrides win and the embedded baseline carries
the convention.
Tests cover the embedded convention, operator overrides, and
inherit:false blocking the embedded layer. No consumer migration yet
— that's Phase 3b. Behaviour is bit-identical for current callers
since none of them consult the new lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.
Schema:
paths:
"*": # literal-segment or "*" segment-wildcard key
paths: # recursive — each step matches one segment
archive:
paths:
"*":
paths:
incoming:
title: "demo"
Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.
Semantics:
- matchGlob: literal key first (case-insensitive on segment),
"*" wildcard fallback.
- mergeOverlay: top wins per-field on scalars; maps merge key-by-
key with top overriding; lists concat-dedupe; Paths replaces
(recursive walker threads it through naturally).
- inherit:false at any on-disk level drops accumulated ancestor
virtual sources AND zeroes chain.Embedded — the operator owns
every rule from that level outward.
- Behaviour is bit-identical when no .zddc declares paths:; the
walker reduces to the prior linear cascade.
Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.
Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.
New pieces:
1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
repo. Single source of truth for the baked-in baseline; intentionally
minimal in Phase 1 (just title + empty acl) so existing deployments
stay bit-identical until Phase 2 starts populating the schema.
2. //go:embed (defaults.go) bakes the bytes into the binary so
shipped deployments don't need the file. Operators who want a
starting point export with:
zddc-server show-defaults > /var/lib/zddc/root/.zddc
3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
layers in the embedded defaults as a baseline below the on-disk
chain. Consumers that want the full effective view consult both;
existing consumers that only read chain.Levels keep working
bit-identically (the new field is additive).
4. New top-level `inherit:` key on ZddcFile. Default true. Set
`inherit: false` on any on-disk .zddc to zero out chain.Embedded
— the operator owns every rule from that level outward. Useful at
the on-disk root to fully reject the embedded defaults; useful at
deeper levels for sandbox subtrees.
5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
dumps the embedded bytes to stdout — same shape as --print-rego.
No flag plumbing needed beyond the existing args walk.
6. Tests: parse-roundtrip on the embedded file, presence in chain by
default, inherit:false drops it, explicit inherit:true is a no-op
versus the default.
Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.
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>
The shared header strip pointed Working/Staging/Reviewing at the slash
form (working/, etc.), which now serves browse per the slash/no-slash
convention established earlier. The user expected those links to open
the stage's tool (mdedit for working, transmittal for staging, etc.) —
which is what the no-slash form serves.
Also drops the .html suffix from the archive target: <project>/archive
(no slash) → archive tool, same as the other stages. The currentStage
recognizer still accepts /archive.html as a fallback for any direct
URLs that survive in bookmarks.
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>
Two related routing fixes:
1. /<project>/archive/<party>/mdl[/] now follows the slash/no-slash
convention uniformly with the rest of the system:
- mdl (no slash) → tables app (default tool for mdl/)
- mdl/ (slash) → browse (ServeDirectory empty-listing fallback)
Previously the slash form auto-redirected to mdl/table.html, which
forced the user into the table view from any party-folder click and
produced a confusing "Unrecognized table URL" error when the
redirect race-conditioned. tableRowsRedirect now only redirects
when a real on-disk table.yaml exists; the default-MDL virtual case
stays in browse via the convention.
New zddc.IsArchivePartyMdlDir helper recognises the canonical
<project>/archive/<party>/mdl pattern at depth 4 (relative path).
fs.ListDirectory uses it to return [] for the missing-on-disk case
so browse renders the empty workspace cleanly. Test updated
(TestServeDirectoryRedirectsDefaultMdl → TestServeDirectoryDefaultMdlNoRedirect).
2. <dir>/.zddc URLs now work at every directory depth.
The dispatcher previously 404'd anything beginning with a dot
(except /.archive and /<dir>/.zddc.html). New IsZddcFileRequest +
ServeZddcFile handlers carve out the raw .zddc leaf so an operator
can navigate to /Project-1/archive/PartyA/mdl/.zddc and inspect
the rules effective at that depth.
Semantics:
- Method: GET / HEAD only. Writes go through the existing admin-
gated form at <dir>/.zddc.html (unchanged).
- ACL: parent directory's read permission gates access; 404
(not 403) is returned to non-readers so existence isn't leaked.
- On disk: file bytes served verbatim with
Content-Type: application/yaml and X-ZDDC-Source: file:<rel>.
- Virtual: when no file exists at this level, a synthetic
placeholder body is returned with a YAML-comment cascade
summary so the reader sees exactly what rules apply here from
ancestors. X-ZDDC-Source: virtual:zddc distinguishes it.
The virtual body parses as valid YAML (`{}` after the comments) so
downstream tooling that consumes the URL isn't confused.
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>
Wires up live alpha-dev iteration on bitnest. With this change a
`.zddc apps: <tool>: <path>` entry overrides the embedded copy for any
of the eight tools, not just five.
Two coupled fixes:
1. zddc.AppNames had a five-entry list (archive/transmittal/
classifier/mdedit/landing) — predating browse/form/tables.
ResolveWithOverride's `if !IsKnownApp(app)` gate silently rejected
those three before ever looking at the cascade, falling back to
embedded with an "unknown app" error.
2. handler.ServeDirectory hard-coded `apps.EmbeddedBytes("browse")`
for the HTML directory-listing fallback, bypassing the apps
subsystem entirely. Now takes an optional *apps.Server and
delegates to appsSrv.Serve(w, r, "browse", chain, absDir) when
wired, so the cascade is honored at bare directory URLs too
(the most common way browse gets surfaced).
Both call sites in main.go and the test signatures in
directory_test.go updated. ValidateFile error message now lists all
eight known apps.
Verified end-to-end on bitnest with a root .zddc apps cascade
pointing at /srv/.zddc.d/source/<tool>/dist/<file>: every `./build`
on the host is now immediately visible after a hard refresh. Iteration
loop is `./build` (or `sh tool/build.sh`) then reload — no container
restart needed, since the apps subsystem reads the path source on
each request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Above the Groups / Projects cards, a horizontal strip of one link per
tool — each pointing at the latest stable single-file build on the
canonical release host (zddc.varasys.io/releases/<tool>_stable.html).
Useful for "try this tool" / offline use without first picking a
project.
Seven links (Archive, Transmittal, Classifier, Markdown, Browse, Form,
Tables). Landing itself is omitted from the strip — clicking landing
from landing is a no-op. Each card has the tool name in the display
serif and a short sans hint underneath. Wraps on narrow widths instead
of scrolling horizontally; sits inside pickerView so it auto-hides on
the per-project landing view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only transmittal had any @media (max-width) rules; the other seven
tools silently break below ~900px. Adds a baseline shared rule that
every tool inherits — desktop-first stays the same, but a tablet in
landscape or a window split next to a document remains usable.
@media (max-width: 800px):
- tighter header padding + gaps
- .app-header__title drops 18px → 16px
- .build-timestamp inside .header-title-group hidden (it's
traceability info, not a primary affordance — still reachable
via help panel)
- header text buttons get a smaller padding so they fit
@media (max-width: 480px) phone-width:
- .app-header switches to column layout
- .header-left and .header-right each span full width with
justify-content: space-between
prefers-reduced-motion was already covered for the page-load stagger.
Each tool can still override in its own css/layout.css; this is the
shared floor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The header is the first thing a user sees. A short staggered fade-in
(logo → title → action button → right-side icons over ~360ms) turns the
instant-pop-in feel into a subtle "the tool is composing itself" beat.
Pure CSS @keyframes (no JS), cubic-bezier(0.2, 0.7, 0.2, 1) for the
"settle in" easing curve. Respects prefers-reduced-motion. Total budget
~260ms before everything is visible — well under the threshold where it
becomes a perceptible delay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
System-default font stack ('-apple-system, BlinkMacSystemFont, Segoe UI,
…') is the textbook generic admin-tool look. The tools have a real point
of view (engineering documents, traceability, immutability); the
typography should reflect that.
Picks:
--font → IBM Plex Sans (400 + 600). UI body text. Distinctive
engineering sans with tabular nums and proper figures.
--font-display → Source Serif 4 (600). Headings, page titles,
.app-header__title. Reads as "document" not "UI label."
--font-mono → unchanged. Platform mono fonts are already excellent
and engineering tools rarely benefit from a custom mono.
Wiring:
- Raw .woff2 files live in shared/fonts/ (~60 KB total, latin subset,
SIL OFL 1.1 — both families)
- shared/fonts.css is base64-inlined data URIs for those three fonts
(~80 KB after b64 overhead). Generated once from the snippet in
shared/fonts/README.md.
- Every tool's build.sh prepends shared/fonts.css before shared/base.css
so @font-face is parsed before any rule references the family names.
- Headings (h1-h6) and .app-header__title now use var(--font-display);
.app-header__title bumped 17→18px and letter-spacing reset since the
serif doesn't need the original sans-text tightening.
- table/code/.tabular-nums get font-variant-numeric: tabular-nums so
tracking-number columns align vertically.
"Ship the record player with the record": zero CDN dependency at render
time. Tools render identically offline and online. Per-tool dist sizes
grew by ~80 KB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There are 76 alert() call sites across the eight tools — three different
ad-hoc error-surfacing patterns (alert, console.error, classifier's own
showToast). Touching every site is a sweep with no judgment payoff:
every alert is "something went wrong, the user should know," which is
exactly what toast at level='error' is for.
Shim is one if-block at the bottom of shared/toast.js. It saves the
native window.alert as window.alertNative (so any truly modal-blocking
call site can opt back in by name), then replaces window.alert with a
function that forwards through window.zddc.toast(msg, 'error'). Effect
is global — every existing alert in every tool becomes a non-blocking,
ARIA-announced (aria-live=assertive) toast that the user can click to
dismiss.
handler/tables.html refreshed by ./build as a side effect (it bakes the
current tables/dist/ into the binary every build).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>