- Editor routing: the CodeMirror editor is now the general editor for editable
text files that aren't markdown — yaml/.zddc keep schema lint + completion +
hover; txt/csv/tsv/json/xml/html/css/js/log/ini/conf/… open as a plaintext
code editor (line numbers, find, save) instead of a read-only <pre>. HTML now
edits its source (was an iframe render); PDF stays an iframe. Mode is yaml-only
(the vendored CM mode); others plaintext, lint gutter suppressed.
- Abandon the .zddc schema FORM (preview-zddc-form.js deleted): .zddc opens in
CodeMirror. Guided dialogs (Manage access, …) are the front door for common
tasks; CodeMirror is the full/raw surface. One fewer half-baked middle layer.
- Manage Access dialog laid out cleanly: grid rows (who fills + shrinks, level
sizes to content) so long emails/levels never overflow; short level labels
with tooltips + a one-line legend; box-sizing fixed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First of the intent-driven actions that replace raw-YAML editing as the front
door. Right-click a folder (or the pane) → "Manage access…" → a dialog of
people + friendly levels; the raw .zddc editor is demoted to "Edit raw policy
(.zddc)…" as the advanced escape. Both gated by the same admin authority.
- browse/js/manage-access.js: reads the folder's on-disk .zddc, presents
principals as View / Contribute / Edit / Manage (admins: membership), plus an
"inherit from parent" toggle (uncheck = make private). Save maps levels back
to verbs (r / rc / rwc / admins:), merges ONLY the access bits into the doc
(every other key preserved), and PUTs. Unrecognised verb strings show as
"Custom" and are preserved untouched.
- Menu: "Manage access…" (guided) is now primary; "Edit raw policy (.zddc)…"
is the escape hatch.
- Self-contained modal + CSS; refreshes the listing on save.
Sets the pattern for the remaining operator tasks (role members, display label,
project convert vars, register a party).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .zddc lint used a hand-kept TOP_KEYS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS list
with bespoke type tags — a parallel grammar that drifted from the Go structs
(we'd already had to patch in party_source/history/etc.). Replace it with one
schema-driven validator over the same JSON Schema that now drives completion +
hover, so lint/completion/hover/form all share ONE grammar.
- Bake zddc.schema.json into the bundle at build time (window.__ZDDC_SCHEMA__),
the exact file the server serves at /.api/zddc-schema. Synchronous + works
offline (file://), where that endpoint is unreachable — drops the runtime
fetch entirely.
- validateZddc() now walks the parsed doc against the schema: type, enum,
pattern, properties, additionalProperties (false|schema), patternProperties,
items, and the recursive $ref:"#" (paths:). Same {keyPath,severity,message}
shape, so findLine + the CM lint helper are unchanged.
- Delete TOP_KEYS/ALLOWED_TOOLS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS + walkObject/
checkValue/addTypeErr. preview-yaml completion now reads getZddcSchema too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalise the front-matter completion into a reusable, provider-based helper
(browse/js/yaml-complete.js) and wire BOTH YAML editors through it. Still fully
deterministic — every candidate and doc string comes from a schema, no AI.
- yaml-complete.js: shared CodeMirror plumbing (indent→key-path, sibling scan,
show-hint, debounced hover tooltip) + two providers:
· flatProvider — a fixed field list (front matter), with an exclude set.
· schemaProvider — a JSON Schema walker that resolves nested key-paths
through properties / additionalProperties / patternProperties and the
recursive $ref:"#" .zddc uses for paths:; keys from object properties,
values from enum / boolean, hover docs from `description`.
- .zddc editor (preview-yaml.js): fetch /.api/zddc-schema once and attach the
schemaProvider on .zddc files — nested-key completion at every level, enum
values (default_tool, dir_tool, views.*.tool), booleans, and hover docs.
Plain .yaml stays lint+highlight only.
- Front-matter editor (preview-markdown.js): refactored to delegate to the
shared helper via flatProvider (excluding the filename-driven identity keys);
the bespoke frontMatterHints is gone — one implementation now.
- Hover-doc tooltip styling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vendor CodeMirror's show-hint add-on and wire deterministic, schema-driven
completion into the markdown front-matter pane — NO heuristics, no AI; every
candidate comes from the converter's own field list.
- Vendor codemirror-show-hint.min.{js,css} (CM 5.65.x add-on); concat in
browse/build.sh after the core CM bundle so it extends window.CodeMirror.
- Server: add a structured `values` enum to convert.FrontMatterField (doctype →
report/letter/specification, numbering → true/false), exposed via the existing
/.api/frontmatter JSON. Tracks the template set; keeps the server as the
single source of truth instead of parsing the hint prose.
- Client: frontMatterHints() completes recognised KEYS at line start (excluding
the filename-driven identity keys and keys already present) and enum VALUES
after `key:`. Picking an enum key auto-opens its value list. Triggered on
Ctrl-Space and automatically as you type (completeSingle:false — always a
menu, never an auto-guess). Themed dropdown for dark mode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes to the markdown editor's identity handling:
- Sync-on-open now only RECONCILES front-matter identity keys the author
already wrote (correcting a stale title/revision/…); it never ADDS them. A
blank or new file opens blank instead of getting an auto-generated title at
the top. The converter derives identity from the filename regardless, so an
empty front matter needs nothing baked in. (Cancel/revert likewise only
touches existing keys.)
- The "Rename file & reopen" flow force-writes the buffer (no If-Match) instead
of routing through save(), which raised the conflict-resolution modal on a
(spurious) 412. The rename is a deliberate user action and the identity edit
that triggered it is consumed by the new filename, so we commit the buffer
rather than interrupting with a merge dialog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the raw <textarea> front-matter editor with a CodeMirror 5 instance —
the same editor family the .zddc previewer already uses (and already bundled in
browse, so no added weight). Gains: YAML syntax highlighting, line numbers, and
the shared js-yaml lint gutter; one consistent editing feel across the two YAML
surfaces.
- Mount fmCM (mode:yaml, lineNumbers, lint gutter, readOnly when not writable)
into a host div; refresh on the next frame so it measures correctly.
- Route every front-matter read/write through fmCM.getValue()/setValue() and
the change event (sync-on-open, dirty tracking, the rename cue, Cancel, save).
- The old textarea placeholder (recognised front-matter keys) becomes a greyed
caption under the header whose tooltip carries the full key list — CodeMirror
5 has no built-in placeholder. Arbitrary keys remain free either way.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a Cancel button alongside "Rename file & reopen" that discards the manual
identity-field edits and restores the filename-derived values (leaving the rest
of the front matter + body untouched), then recomputes dirty + the cue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename-cue div used an inline display:flex, which outranks the `hidden`
attribute's [hidden]{display:none} — so fmWarn.hidden=true never hid it and an
empty yellow box showed whenever the cue had nothing to say. Control visibility
via style.display ('none'/'flex') instead of the hidden attribute.
Also surface a status line when sync-on-open rewrites the front matter to match
the filename, so the change isn't silent ("Front matter synced to filename —
review and save").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The four identity fields (tracking_number/title/revision/status) come from the
filename — the single source of truth that the register/WORM/ACL key off, never
the front matter. But they must stay in the front matter for the converter's
title block. Resolve the long-standing "front matter disagrees with filename"
nag without coupling the system to ZDDC naming:
- Sync-on-open: when the filename is ZDDC-parseable, mirror its identity into
the front matter on open; if that corrects anything the buffer opens dirty so
a save bakes it in. No-op for non-ZDDC names — the editor stays fully usable
on arbitrary directories, where the front matter is the sole source.
- A manual edit to an identity field is treated as a cue to RENAME the file
(the filename owns identity), not a value to keep: the old "filename wins,
ignored" warning is replaced by an explicit "Rename file & reopen" button
that saves, renames to the implied ZDDC name, and reopens it (server mode via
the ?file deep-link; FS-Access via the moved handle).
- Reword the RecognizedFrontMatter hints from "the filename wins on mismatch"
to "mirrors the filename — rename the file to change it".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three .zddc previewer fixes reported against the browse YAML editor:
- Lint no longer flags valid keys. browse/js/preview-yaml.js TOP_KEYS had
drifted from the Go decoder (zddc/internal/zddc/file.go): party_source,
history, history_globs, records, auto_own_roles, received_path,
planned_response_date, planned_review_date, field_codes were all reported
as "unknown key". Add them with appropriate type tags plus an 'object'
case in checkValue for the free-form maps (records, field_codes).
- The ".zddc schema" pill is now clickable (↗) — opens the canonical JSON
Schema the lint mirrors at /.api/zddc-schema (no-auth, read-only).
- The synthetic virtual-.zddc header comment named an internal source path
(internal/zddc/defaults/'s paths: tree) that an operator can't act on. It
now names the operator-facing artifact: the built-in defaults bundle,
exportable/overridable as a root .zddc.zip via `zddc-server show-defaults`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The intro <p class="help"> was a 2–3 sentence paragraph; in a narrower
preview pane it wrapped to ~9 lines (~170px tall), pushing the Title field
far down. It was also redundant with the read-only "Structure & advanced"
section + the "Edit raw YAML" button. Tighten it to one concise line
("Project options. Structural keys are read-only — use Edit raw YAML."):
now 20px wide / 41px on a narrow pane (was up to ~170px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
grid.js was classifier-only; make it embed ANY embeddable full-page tool the
cascade resolves as default_tool — classifier (incoming/), transmittal
(staging/), archive (the index) — as an iframe scoped to the current dir
(<dir>/<tool>.html). This is the browse-as-shell bridge from the ADR: browse
stays the top-level app and the heavy tools open in-pane (the gridView), so
navigating to staging/ or archive/ inside browse shows transmittal/archive
without leaving the shell, with ?view=browse falling back to the folder
listing (and the standalone tools still served directly at the no-slash URL).
EMBEDDABLE = {classifier, transmittal, archive}; tables/forms embed in the
preview pane instead, landing/browse don't self-embed. resolveViewMode keys
off grid.availableHere() (now generic). Validated in a containerized browser:
each dir embeds its tool, ?view=browse overrides to the listing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).
New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.
No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.
elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.
Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A directory whose cascade default_tool is "tables" (mdl/rsk/ssr and any
operator-configured table dir) now shows in the browse tree as a leaf with
a table icon — no expand chevron — and clicking it opens the tables tool in
the preview pane (an iframe scoped to the dir, mirroring grid.js's
classifier embed) instead of expanding/navigating into the folder.
Detection rides the cascade, not hardcoded names: the directory listing now
carries a per-entry default_tool hint (listing.FileInfo.DefaultTool, set via
zddc.DefaultToolAt for both on-disk children and the virtual canonical peers
mdl/rsk/ssr). Browse's util.isTableLeaf(node) keys off it; tree.js renders
the leaf, events.js routes its click/Enter to the preview (excluding it from
expand/navigate), and preview.js renders the iframe at the dir's NO-SLASH
URL (the default_tool route — <dir>/tables.html 404s for a virtual dir).
Server mode only (the hint is absent on file://, so offline folders stay
ordinary expandable dirs). Validated end-to-end in a containerized browser:
mdl/rsk/ssr are leaves, normal folders keep their chevrons, and clicking mdl
loads the tables view inline without navigating.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The config bundle followed the old elevation gate: only an *elevated* admin
could browse or edit it. Bring it in line with the standing config-edit
model — a subtree admin / `a`-verb holder over the bundle's directory may
browse AND edit it without toggling. Elevation stays purely additive.
activeAdminForBundle → configEditorForBundle (zddc.IsConfigEditor, no
Elevated). Gates both the existence-hiding visibility check and the
ServeZipWrite path. Deliberately scoped to config-EDITORS, not all readers:
one .zddc.zip packs many subtrees' policy into a single file, so wide read
would leak a tightened subtree's rules — per-level transparency is served
by ServeZddcFile (already read-ACL'd) instead.
Client: isEditableZipMember drops the isElevated() check — the server gates
bundle visibility on config-edit authority, so if a member is visible the
session can edit it.
Tests: TestDispatchBundleAdminView now expects an un-elevated admin to SEE
the bundle (non-editor reader still 404); TestDispatchBundleAdminWrite adds
an un-elevated config-editor write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first section's heading top margin (.6rem) stacked with the intro
paragraph's bottom margin (.8rem), leaving ~1.4rem of dead space above
the Title label. Drop the heading's top margin for the first section
(new `tight` flag in section()) and trim the intro's bottom margin to
.5rem. Later sections keep their inter-section gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create actions duplicate the right-click context menu, so remove
them from the tree-pane toolbar. Reorder the toolbar so the Sort + Hidden
view controls sit ABOVE the autofilter box. Drops the now-dead toolbar
New-button click handlers and their create-gate enable/disable logic
(canCreateHere still gates the context-menu create items).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admins opt into admin powers via an on-page switch instead of only
?admin=true. The toggle renders ONLY for users the server reports
can_elevate, reusing each tool's existing header placeholder (or
creating one) and floating it bottom-right via fixed positioning.
Admin mode is now EPHEMERAL — scoped to the page you armed it on:
- the zddc-elevate cookie is session-scoped (drops the 30-min Max-Age)
- pagehide clears it, so navigating away / closing drops admin
Because a reload would race the pagehide-clear, every arm/drop path
(toggle, ?admin= URL, banner "Drop admin") now applies IN PLACE and
emits a `zddc:elevationchange` event. browse listens for it and
re-fetches the listing (server-computed verbs) + re-renders the open
preview, so editor affordances reflect the new elevation without a
manual reload.
Validated end-to-end in a containerized Chromium (Playwright over CDP)
against a local zddc-server: the toggle renders for can_elevate, arming
sets the session cookie + armed chrome, "Drop admin" and navigate-away
both clear it, and ?admin=true still arms via the same funnel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The primary editor for a .zddc is now a FORM, not raw YAML — so configuring a
project doesn't require understanding the cascade. preview-zddc-form.js fetches
the .zddc JSON Schema (/.api/zddc-schema) and renders:
- OPTION fields editable — title, admins (email list), roles (per-role member
lists, + add role). These are the "blanks an operator fills."
- STRUCTURE + unrendered keys (paths, worm, tools, behaviors, field_codes,
display, …) shown read-only in a collapsed "Structure & advanced" section
(classified by the schema's x-zddc-tier).
- An "Edit raw YAML" escape that hands off to the CodeMirror editor.
Save merges the edited option values back into the parsed document — preserving
every structure/unrendered key — and PUTs the YAML via util.saveFile, which
works for an on-disk .zddc AND a .zddc.zip bundle member (ServeZipWrite).
Edit authority is the existing gate (ActionAdmin 'a', or an editable bundle
member); non-admins get a read-only form.
Wired as the primary .zddc editor in preview.js (before the YAML plugin) and
into the unsaved-changes guard. Raw YAML remains the power-user fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .zddc/markdown editors marked every zip member read-only. Add
util.isEditableZipMember (member of a .zddc.zip + session elevated) and let
those through canSave in both editors — so an elevated admin can open a bundle's
policy .zddc (or any member) and save it, which PUTs to the member URL where the
new server-side ServeZipWrite handles the in-place rewrite + in-zip history. The
server (bundle gate + active-admin) is the real authority; this just drives the
editor UX (mount editable, label "config bundle" instead of "read-only (zip)").
Content-archive members stay read-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: the doctype templates render $revision$, $status$, $tracking_number$
and $title$, so they belong in the recognised front-matter list — added them
(alongside the existing title) to convert.RecognizedFrontMatter.
These four are the document's canonical identity, sourced from the ZDDC
filename. Policy (chosen): the filename WINS — the rendered doc always uses the
filename-derived value (the HTML/PDF templates read it from the filename-derived
pandoc -V flags, which override YAML metadata). Front matter must not silently
diverge, so:
- their hints now read "set by the filename (the filename wins on mismatch)";
- the markdown editor shows a non-blocking warning when front matter sets one
of the four to a value differing from the filename (gated on a conventional
ZDDC filename — non-conventional files have no canonical identity, so front
matter stays free there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The markdown editor's YAML front-matter pane was a bare textarea, so authors
had no way to discover the keys the converter honours — notably `doctype:`
(report|letter|specification) and `numbering:`, which have no other source.
Add a single server-side source of truth, convert.RecognizedFrontMatter() +
convert.FrontMatterPlaceholder(), and expose it as JSON at GET /.api/frontmatter
(handler.ServeFrontMatterTemplate; read-only, no auth — leaks only documented
field names). The browse editor fetches it once (server mode) and sets the
front-matter textarea's placeholder to the greyed hint, so an empty pane shows
the recognized keys with one-line hints. It's placeholder-only: it inserts
nothing, vanishes on the first keystroke, and arbitrary keys remain free —
front matter is still passed through to pandoc untouched. file:// mode shows no
placeholder (conversion is server-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Export context-menu offered only md↔docx↔html (a symmetric set), so PDF
— which the server supports only as md→pdf — was missing. The markdown
editor's DOCX/HTML/PDF buttons hardcoded their own list, so the two could
drift.
Introduce a single source of truth in download.js: EXPORT_MATRIX mirrors
zddc/internal/convert.Convert() exactly (md→docx|html|pdf, docx→md|html,
html→md|docx), exposed as download.exportTargets(ext) + download.convertUrl().
The Export submenu and the editor's buttons both consume it, so a .md file now
offers PDF in the menu and the two surfaces can never disagree. PDF stays
markdown-only (no docx→pdf / html→pdf path exists server-side).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an "Export" item to the row context menu with a submenu:
- a folder offers ".zip" (reuses download.downloadFolder; works offline + server)
- an md/docx/html file offers the OTHER two formats, each triggering a
server-side conversion download via the new download.exportFile (builds the
sibling-extension URL and lets the browser pull the converted bytes). File
conversion is server-only, so it's hidden in offline (FS) mode; a zip is
already an archive and gets no Export.
menu-model's toMenuItem now passes a descriptor's `items` through as a submenu
(resolved against the captured browse ctx) instead of only emitting action rows.
Verified: 11/11 browse Playwright specs pass (incl. menu/context + Download ZIP);
a logic harness confirms the per-type submenu contents and that clicks route to
download.exportFile / downloadFolder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the generalized view model: `.zddc` declares, per URL shape,
which tool renders and where its supporting config lives.
- ZddcFile.Views map[string]ViewSpec{Tool, Config}; shapes "dir" / "dir_slash"
/ "file". config is a filename resolved under <dir>/.zddc.d/. Pure data — no
behaviour; presentation/routing only (ACL/WORM/admin stay server-enforced).
- lookups.ViewAt(root, dir, shape): cascade leaf→root first-match, with
default_tool / dir_tool honored as sugar for dir / dir_slash (semantics
unchanged). No merged map — resolved per-shape like DefaultToolAt.
- cascade summary, isZero/is-empty checks, and validation (tool ∈ AppNames;
config a path-bounded plain filename). Client .zddc validator (preview-yaml.js)
gains a `views` key + `viewmap` case.
Additive only — nothing consumes Views yet (the generic resolver + dispatch
wiring + recognizer retirement follow). go build + zddc/handler tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Menu refinements per review:
- "Open" now navigates into a folder (rescope); the separate "Navigate into"
item is removed. Zip → expand inline (can't navigate in); file → preview.
Inline expand stays on single-click / chevron / arrow keys.
- "New markdown file" → "New file".
- New folder / New file / Rename / Delete are now HIDDEN when the user lacks
the create/write/delete capability (folded into appliesTo) instead of shown
greyed — a guest gets a lean menu; users who can still see them. New
folder/file also remain on the toolbar.
- "Edit access rules…" is shown only when the user can actually edit them
(admin verb 'a' or subtree/site admin) — hidden otherwise, not greyed.
- Removed "Copy path" / "Copy name" — the info box (hovercard) carries the
name and a clickable URL now.
Info box (hovercard): dropped the on-disk "Path" row; the "URL" is rendered as
a clickable hyperlink (via the existing kvLink helper) — the shareable
reference, openable or right-click-to-copy.
Tests updated: file row omits New folder/file + Copy + Navigate; permission-
gated Rename/Delete are HIDDEN for a read-only server node and PRESENT for a
read/write/delete node (pure menuModel unit). All browse+conflict+diff green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the browse menu/tree interaction into a declarative, contextually
honest model and moves view settings onto a toolbar — the menu is the UI to
the system, so it should be familiar, inviting, and only ever offer what
applies.
New declarative menu model (browse/js/menu-model.js):
- Every action is one descriptor with a TYPE predicate (appliesTo) and a
CAPABILITY predicate (enabled)+tooltip. Row/pane menus are projections over
it; separators are derived from group changes. Designed data-shaped so a
future server-sourced manifest (zddc.zip) can supply/extend it.
- Hybrid visibility: type-inapplicable actions are OMITTED (New folder on a
file, Expand on a file); permission/role/tier-gated actions are SHOWN
DISABLED with a reason — so a lower tier sees what a higher role unlocks.
- Roles are NOT hardcoded: ordinary actions gate on the verbs the server
returns (node.verbs / path_verbs), so any operator-defined role works. Only
the two intrinsically-special tiers are recognised by name — site admin
(is_super_admin) and project/subtree admin (path_is_admin), surfaced as the
"Edit access rules…" item; both come from the existing /.profile/access.
- The headline fix: New folder / New markdown file no longer appear on file
rows (they target a folder or the current dir).
events.js: deletes the ~350-line inline buildTreeRowMenu/buildPaneMenu/
SORT_BY_ITEMS; opens menus via menuModel projections through one openRowMenuFor
/openPaneMenu path shared by right-click, the hover kebab, and the keyboard
menu key (ContextMenu / Shift+F10). Injects action impls via menuModel.configure
to avoid a circular dep. Prefetches the scope /.profile/access (memoised) on
load/rescope/refresh/popstate so menus never fetch at open time.
Discoverability + a11y: a per-row ⋯ kebab (tree.js + new icon-ellipsis sprite,
revealed on hover/selection/focus) opens the same menu; keyboard menu key
supported.
Toolbar: Sort + Show-hidden moved OUT of per-row right-click menus into the
tree-pane toolbar, plus New folder / New file buttons (act on the current dir,
greyed with a reason when create access is lacking). Help copy updated.
Icons: dropped the 3 stray emoji from menu items (consistent, VS Code/Finder
style); only new sprite is the kebab's icon-ellipsis.
Tests: +5 browse specs (file row omits New-folder; folder row shows it; a
read-only server node greys Rename with a "write access" tooltip via a pure
menuModel unit; toolbar Sort/Show-hidden drive state + New buttons present;
kebab and Shift+F10 both open the menu). All 23 browse+conflict+diff green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.
- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
(or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
the PUT response (so save→edit→save adopts the new version and doesn't
false-conflict); throws ConflictError (.status===412) on a precondition
failure so callers branch cleanly. New saveCopy() parks a conflicting edit
as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
from the content GET (the listing JSON carries no per-file etag); threaded
into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
(reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
adopt the returned etag on success, and on 412 open the dialog (Overwrite
re-fetches the current etag then re-saves — re-conflicts on a third writer
rather than blind-forcing; Reload clears dirty first so the renderInline
guard skips its confirm). FS-Access mode sends no precondition (no
concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
on 412, new-etag returned, force omits the precondition, dialog renders the
diff and each action resolves via its callback. Drives the fresh dist build
over file:// with a stubbed fetch (the test binary embeds the committed
browse.html, not dist, so a server-mode E2E would run stale code).
All browse + diff + conflict specs pass (18).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
downloadFsSubtree pre-read every file's arrayBuffer() and handed the raw
ArrayBuffer to JSZip, so the entire subtree's bytes sat in the JS heap at
once before zipping — the likely OOM on a large local folder despite the
size warning. Hand JSZip the File (a Blob backed by disk) instead; it reads
each lazily during generateAsync, dropping peak memory to roughly the zip
output plus JSZip's working set.
Also document, on downloadUrl, why server-side download errors aren't
surfaced as toasts: the <a download> click is fire-and-forget, and the
folder path targets zddc-server's streamed virtual "<dir>.zip" endpoint —
routing it through fetch() to make errors catchable would defeat the
streaming for arbitrarily large archives. Left as a known, documented
limitation rather than a buffering regression.
All 6 browse Playwright specs pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure cleanup, no behavior change:
- tree.js: drop the unused setSort() method (only setSortExplicit is wired,
via the toolbar dropdown) and its doubly-stale comment (claimed there was
no sort UI — there is).
- app.js: remove the augmentRoot/passThroughEntries identity stub. It was a
leftover from when browse merged virtual canonical folders client-side;
zddc-server emits them now and nothing reads window.app.modules.augmentRoot.
- loader.js: splitExt now delegates to window.zddc.splitExtension (identical
behavior — lowercased, dotfile/trailing-dot → '') per the CLAUDE.md rule
that extension handling goes through window.zddc; drop the unused export.
- upload.js: remove the dead `else if (refreshUrl)` comment-only branch (and
the unused refreshUrl var) — refreshListing is always present since it was
exported.
- init.js: declare scopeCanonicalFolder, scopeOnPlanReview, and showHidden in
the state initializer. They were read/written across modules but never
listed in the canonical state shape (implicit undefined).
All 6 browse Playwright specs pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every async flow that ends by replacing the tree root (refreshListing,
rescopeServer, reloadDir, and the app.js back/forward popstate handler) ran
without any concurrency guard. Two overlapping listings — a double-click into
a folder, a refresh fired mid-load, rapid back/forward — could resolve out of
order, so a slow fetch would setRoot/pushState on top of a newer navigation
and leave the tree out of sync with state.currentPath and the URL bar.
Introduce a shared monotonic nav-sequence token in events.js (beginNav /
isCurrentNav, exported so the app.js popstate handler joins the same
sequence). Each flow claims a token before its fetch and bails if a newer
navigation has started by the time it resolves — last navigation wins,
stale ones drop their result before mutating anything. navigateIntoFolder's
FS branch is reordered to mutate scope state only after a successful fetch +
token check, so a bail leaves the previous scope intact instead of
half-swapped.
Duplicate-fetch race fixed at the source: tree.loadChildren took only a
`loaded` check, so rapid Enter/ArrowRight key-repeat or a double-click
landing during a single-click's load fired two concurrent fetches that raced
in setChildren. Added a `loading` in-flight flag that serializes per-node
loads — the second caller is a no-op until the first resolves. This also
removes the need to await the fire-and-forget toggleFolder calls in the
keyboard handler.
Also surfaces reloadDir fetch failures via statusError instead of swallowing
them (the success path's create/rename/delete toast no longer hides a failed
refresh).
All 6 browse Playwright specs pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nine copies of escapeHtml (some escaping single-quotes + handling null,
others not), two byte-identical hashContent hashers, two saveContent
writers, two isZipMemberNode predicates, the ISO-date + YAML-quote helpers
duplicated across the workflow modals, three /.profile/access email
fetchers, and three byte-size formatters had all drifted across the browse
modules. Hoist a single browse-local window.app.modules.util (no new global;
concatenated right after init.js) and alias the call sites to it.
Reliability fix folded in: the YAML editor's saveContent skipped the
upload.ensureWritable() escalation that the markdown editor performs, so
saving a .yaml/.zddc file to a read-only-picked local folder failed where
markdown succeeded. Both now go through util.saveFile, which always
escalates — the shared writer makes the two editors impossible to drift
apart again.
Canonical escapeHtml is the strict superset (escapes & < > " ', null →
"") so it's a safe drop-in for every prior variant. fmtSize gains the GB
tier everywhere (history.js previously capped at MB). Also removes the dead
stage.js fetchSelfEmail (defined, never called).
Net −200 lines across the modules. No behavior change beyond the save fix;
all 6 browse Playwright specs pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Workflow data-consistency cleanup across the transmittal modules.
Stale-tree / re-trigger hazard: Stage, Unstage, and Accept reported success
with "reload to see the move" and never refreshed, leaving the moved item at
its old location in the tree — inviting the user to re-fire the action on a
folder the server had already moved. They now refresh the current listing on
success. This also revealed that events.refreshListing was never exported,
so upload.js's comment-upload refresh (which guards on it) was silently a
no-op — exporting it fixes that path too.
Non-atomic stage: "New folder" does mkdir then a separate move; if the move
failed after the mkdir succeeded the user got a generic "move failed" with an
unexplained empty folder left behind. invokeStage now tracks whether it
created the folder and says so, and refreshes so the orphan is visible.
Double-submit: Accept / Plan Review / Stage / Unstage take a module-level
busy guard so a second menu click while a POST is in flight is ignored.
Modal listener leaks (verified): the Escape keydown handler in accept,
plan-review, and create-transmittal was only removed on the Escape path —
cancel / overlay-click / submit all leaked a live document listener bound to
a detached modal. Bound once and removed in close() (matching history.js).
history.js restore: split the PUT from the post-restore refetch so a refetch
error can no longer surface a misleading "Restore failed" after the restore
has already persisted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The markdown/YAML preview editors were never disposed when switching to a
non-editor file: dispose() was only called from inside the same plugin's
render(), so md→PDF/image/YAML overwrote the pane via innerHTML and leaked
the Toast UI instance, its DOM, and document-level resizer drag listeners.
Unsaved edits were also discarded silently on any file switch (including
arrow-key auto-preview), and debounced change handlers could resolve after
an editor was disposed and write the wrong file's dirty/hash state.
preview.js now owns editor lifecycle centrally in renderInline:
- disposeEditors() up front before replacing the pane (fixes the leak for
every md/yaml → anything switch).
- dirty guard: deliberate switches (click/Enter/menu) confirm before
discarding; auto previews (keyboard cursor walking the tree, opts.auto)
leave the dirty editor in place rather than nagging per keystroke;
re-selecting the file already being edited is a no-op.
- a renderSeq token bails late-arriving loads so a slow file can't paint
stale content into the pane after a newer selection.
- clearPreview() exposed and used by rescope (events.js) and popstate
(app.js) so those resets dispose the editor instead of leaking it.
- beforeunload warns when an editor is dirty at page exit.
preview-markdown.js: per-mount AbortController wired into the resizer
document listeners so dispose() detaches them even mid-drag; debounced
change/save/convert handlers guard `currentInstance !== instance` so a
disposed editor's callbacks can't corrupt the active file; expose
isDirty()/currentNode().
preview-yaml.js: track dirty/node state, guard the change handler the same
way, expose dispose()/isDirty()/currentNode().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
browse: the party picker reads the ssr/ registry (the authoritative party
list) and creates at physical peer paths <project>/<peer>/<party>/…;
"register new party" writes ssr/<party>.yaml first (party_source: ssr).
stage.js + accept-transmittal.js repointed to the top-level workspace peers
(working/staging/incoming) — received/issued + plan-review stay under the
WORM archive.
tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE
level into the party subdirs CLIENT-side (works online AND offline), with
$party from the server-injected row content (or derived from the subdir
offline). Rows carry the <party>/ prefix so reads/edits hit the real
per-party path. The server just lists the peer root normally (party subdirs
+ synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows
are dropped in favour of this dual-mode client recursion.
Full Go suite + all 256 Playwright tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DOCX/XLSX preview: add renderDocx (docx-preview) and renderXlsx (SheetJS)
to shared/preview-lib.js — the natural home alongside renderTiff/
renderZipListing, reusable by every tool. browse dispatches Office files
to them in both the inline pane and the pop-out window via the existing
preview.isOffice() check, and browse/build.sh now bundles the
docx-preview + xlsx vendors. Renderers degrade to a friendly message if a
tool doesn't bundle the vendor.
Overflow fix: .md-shell used `grid-template-columns: 280px 1fr`. A bare
`1fr` is `minmax(auto, 1fr)`, whose `auto` floor is the editor's
min-content width (Toast UI's toolbar) — so the content track refused to
shrink and the whole shell overflowed #previewBody as the window narrowed
instead of the editor reflowing smaller. Switch both tracks to
minmax(0, …) in the CSS and in the three JS spots that rewrite the
columns on sidebar-drag, and give .md-shell__sidebar min-width: 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the markdown edit-history store from content-hashed blobs +
log.jsonl to one self-describing file per save:
.history/<stem>/<ts>-<email>.<ext>
The filename IS the audit (colon-free UTC timestamp valid on SMB/Azure
Files + the authoring email); listing the directory is the history. No
sidecar log, no hashing. A byte-identical save is a no-op; a pre-existing
file lazy-seeds its current bytes (author "unknown", stamped at mtime).
Reverting copies an old snapshot back (records as a fresh save). Snapshots
are kept forever.
Fixes the 404 reading history: reads no longer require history to be
*currently* enabled — ServeTextHistory serves whatever .history/<stem>/
exists (empty list when none); the dispatch drops the EffectiveHistory
gate for reads. WRITES stay gated by the history: flag. (The 404 came from
the aggregator refactor turning history off on project-level working/,
which made already-recorded snapshots unreadable.)
Renames: an in-place rename carries .history/<stem>/ to the new name
(serveFileMove); a cross-dir move leaves it behind.
Defaults: history: true now ships on the three live-editing slots —
working, mdl, rsk — at both the project-level nodes and the per-party
folders. It's a .zddc cascade key, so operators override per project.
Records (.yaml in mdl/rsk) keep their separate record-history path.
Browse history viewer updated to the filename-based version id (id ←
sha). Tests rewritten for the per-file scheme + rename behavior + SMB-safe
names; HistoryAt defaults test updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The YAML viewer mounted CodeMirror with readOnly:'nocursor', which removes
the textarea from focus and kills click-drag selection — so copying a
read-only .zddc snippet was impossible without flipping on admin mode to
make it writable. Use readOnly:true instead: non-editable but focusable,
so the user can select and copy. autofocus:false still keeps arrow-key
tree navigation intact until they click into the editor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hovering a folder/file now shows "Your permissions" (the rwcda verbs you
hold there) and "Your roles" (the cascade roles you're a member of at that
location — e.g. document_controller, project_team). Roles are cascade-
scoped, so they can differ by location; this answers "does the system think
I'm a document_controller here?".
- server: RolesForPrincipalInChain(chain, email) resolves the caller's role
memberships at a path (honouring fences/resets, incl. embedded standard
roles); /.profile/access?path= now returns path_roles alongside path_verbs.
- browse hovercard: "Your permissions" from node.verbs (sync); "Your roles"
async-filled from /.profile/access?path= via zddc.cap.at (memoised).
Offline mode shows "local folder (filesystem)" and no roles row.
Tests: RolesForPrincipalInChain unit tests (member union, wildcard members,
non-member, fence-hides-ancestor-role, empty email).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Local folders are picked read-only, so create/edit/rename/delete were
either disabled or would fail. Now offline mode supports the same CRUD as
server mode, bounded only by what the filesystem grants:
- upload.js: ensureWritable() escalates the picked root to readwrite via
the FS-Access permission prompt on the first mutation (one prompt, then
granted for the session; requires the user gesture every caller has).
makeDir/makeFile gain FS-API branches (getDirectoryHandle/getFileHandle
{create:true} + createWritable) resolved through handleForDir; removeNode
and renameNode (already FS-API) now escalate first.
- preview-markdown.js: the markdown editor's save escalates before
createWritable, so editing a local .md persists.
- events.js: New folder / New markdown file menu items are enabled whenever
there's a writable target (server, or a picked local folder) via
canCreateHere(); rename/delete were already gated by canMutate (FS-API).
The aggregator party-picker stays server-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Creating a folder/file at a project-level folder-nav aggregator root
(working/staging/reviewing) used to error or silently shadow — the slots
are virtual and content is party-scoped. Now browse opens a party picker
that targets archive/<party>/<slot>/<name>, with a "+ New party…" option
(server-gated to the document_controller via the existing archive/ ACL).
- events.js: aggregatorRoot detection + openPartyPicker modal (mirrors the
stage.js modal), createInAggregator routes the create to the canonical
archive path; rewriteAggregatorPath handles right-clicking a party row
shown in an aggregator listing so it never re-prompts.
- server: serveFileMkdir now 409s a mkdir inside an aggregator
(rejectProjectAggregatorMkdir) with a pointer at archive/<party>/<slot>/,
instead of letting the write fall through to an unreachable shadow dir.
Reverts the prior session's project-level creator-owned working/ folders
(per the design decision to make all three folder-nav slots uniformly
party-scoped): working/ is a pure virtual aggregator again like
staging/reviewing — drops the working/ history+auto_own+acl defaults, the
EnsureCanonicalAncestors working exception, the working-root document-
controller file gate (serveFilePut/Move) and zddc.IsRoleMemberAt. Per-party
archive/<party>/working/ keeps its own history + auto-own.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).
- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.preview-pane__body was flex: 1 + display: flex; flex-direction:
column but without min-height: 0. The flex item's default
min-height is min-content (its natural content size), so when the
YAML editor's CodeMirror viewport carried many lines, the body
grew to fit the editor instead of letting the editor scroll
internally. The chain ran out of viewport before reaching the
editor's bottom edge; the body's own scroll bottomed out at a
height that still cropped the last few lines.
Adding min-height: 0 lets the body shrink to its flex-allocated
size so CodeMirror's internal scroll takes over correctly. Same
root cause as the standard flex+overflow papercut documented in
half the CSS guides on the internet — fine to add unconditionally,
no other consumers of .preview-pane__body care.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The persistent #statusBar strip held whatever last-action message
was written ("Loaded N items", "Created folder X", error text, …)
and stuck around indefinitely, overlapping content while adding
little value. Deleted the strip; existing statusInfo/statusError
call sites now thunk through window.zddc.toast (the shared toast
helper every tool already bundles).
- Same function signatures: events.statusInfo /
events.statusError keep working without touching the 70+ call
sites across app.js, download.js, events.js, etc.
- plan-review.js had its own private statusInfo/statusError pair
(duplicated the DOM write); updated to route through
zddc.toast as well.
- statusClear becomes a no-op — toasts fade on their own (5s
info, 8s error via cap-toast) and the toast helper's
single-toast policy guarantees only the latest is visible.
Removed: #statusBar div from template.html, .status-bar / .is-error
/ .is-info / --error / --info rules from base.css and tree.css.
Zero remaining `statusBar` or `status-bar` references in the built
browse.html. Full Playwright suite green (243/0/4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three modes again behave consistently after Part 3's per-entry
gating:
1. file:// (FS Access API picker) — fromHandle leaves verbs unset
(now undefined, not ""). The events.js Rename/Delete gates
skip the cap.has cascade check when typeof node.verbs is not
'string', so the items stay enabled per the original canMutate
contract.
2. Caddy file-server — fromServerEntry sees no verbs in the
listing and preserves undefined. Same skip applies; Rename /
Delete stay enabled but the underlying server will 405 the
POST/DELETE (same pre-Part-3 behavior). Markdown/yaml editors
still mount read-only via cap.has's writable fallback.
3. zddc-server — verbs is always emitted (possibly as "" for an
explicit zero grant). cap.has interprets the string and the
gates apply.
The previous "verbs ?? ''" normalisation collapsed (1)+(2) into the
explicit-zero case, which incorrectly disabled Rename/Delete in
offline mode. Tri-state verbs (string non-empty / string empty /
undefined) restores the intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browse's row context menu and in-place editors now consult the
server-computed verbs string (via window.zddc.cap.has) before
enabling write/delete affordances:
- Rename… disables when the entry's verbs lacks 'w'.
- Delete… disables when verbs lacks 'd'.
- Markdown editor mounts read-only when verbs lacks 'w'.
- YAML editor mounts read-only when verbs lacks 'w' for regular
files, 'a' for the .zddc placeholder (matches the file API's
ActionAdmin gate at that URL).
Disabled menu items carry a tooltip naming the missing access
("You don't have write access to this item.") so the user discovers
which permission is missing rather than just seeing a greyed row.
shared/context-menu.js gains a `tooltip` field (string or fn(ctx))
that sets the row's title attribute.
canMutate() stays as the source-side gate (server vs FS-API
reachability, zip-member / virtual filtering); verbs gate composes
on top. Server-side ACL still has the final say if a stale client
ever tries the action.
cap.has() falls back to node.writable for 'w' when verbs is absent,
so offline FS-API mode keeps working without a server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:
ssr/mdl/rsk tables rollups across parties with a
synthesised $party source-party column
working/staging/ browse folder-nav listings of parties with
reviewing non-empty content in the slot; per-party
URLs 302-redirect to archive/<party>/<slot>/
Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.
Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.
document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>