- Header: required columns (from the row schema's `required` list) get a red
`*` marker so mandatory fields are obvious.
- Save: before the PUT/POST, a client-side required-field check marks the empty
cells and blocks the save with a clear reason; the server's 422 remains the
authority and its messages surface the same way.
- Inline error row: when a row can't be saved (required-missing, 422 validation,
409 duplicate, 403 permission, network, HTTP error), the reason now shows in a
full-width message row directly beneath the offending row — not just a hover
tooltip or the far-off status bar. Cleared on a successful save / reload.
- Editor row-indexing counts data rows only (tr[data-row-id]) so the inserted
error rows never offset cell navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tables tool referenced --spacing-sm/md/lg (14×) but they were never
defined and used no fallbacks, so every padding/margin/gap collapsed to 0 —
table cells had no vertical padding and the table sat flush to the viewport
edges. Define --spacing-sm/md/lg (+ alias the --color-*/--radius-sm names the
tool uses) in shared/base.css, and give .table-main a clear left/right gutter
(padding: md lg). Fixes every tables view (profile/tokens/diagnostics/mdl).
Profile: clicking a project (or admin-subtree) row now opens that scope's
.zddc INFO FORM in the browse editor (via the ?file=.zddc deep link →
selects + previews the .zddc → schema-driven Title/Roles/Admins form),
instead of navigating into the project's files. Diagnostic rows still link
to their endpoints.
Validated in a containerized browser: 24px side gutters + padded rows;
clicking Proj → /Proj/?file=.zddc → the .zddc form editor. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The profile page links to /.profile/{config,logs,whoami}, which returned raw
JSON — so a browser click landed on raw JSON. Render them through the tables
engine instead (header chrome + sortable/filterable columns), content-
negotiated: browsers (Accept: text/html) get the table; scripts (Accept:
application/json) still get the unchanged JSON. New serveDiagTable helper +
kvRow/kvColumns: logs → time/level/message/detail rows (newest first);
config + whoami → Field/Value rows. Dropped the deep effective-policy row
from the profile table (kept JSON-only, not linked).
Extends api-actions.js with a `readOnly` context flag so a server-injected
read-only table (no apiActions) still hides the file-model toolbar buttons
(+ Add row / Save). Export CSV stays.
Completes the bespoke-server-page → tables-engine consolidation: tokens,
profile, and the three admin diagnostics now all render declaratively with
shared chrome; per-role gating stays server-side (diagnostics are elevated-
super-admin only). Full Go suite green; verified in a containerized browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the bespoke profile page. /.profile now renders through the shared
tables engine (header chrome incl. the profile menu) from a server-injected
context: the caller's "Effective access" — projects + admin subtrees — as
clickable rows (rowNav opens each), identity in the description, and an
apiActions "+ New project" (name → POST /.profile/projects, gated on
can_create_project; roles are added afterward by editing the project's
.zddc, which is now standing-editable). Super-admin diagnostics
(config/logs/whoami/effective-policy) stay discoverable as rows linking to
their unchanged endpoints — gated on IsSuperAdmin so a non-admin's context
never even names them.
Dropped as redundant/niche: the in-page theme picker (the header has the
theme button), the localStorage inspector, and the "editable .zddc" links
(those files are now standing-editable in browse).
Extends the generic apiActions layer (tables/js/api-actions.js) with
`fixed` constant fields (e.g. parent="/"), `required` field validation, and
`rowNav` clickable rows (capture-phase, so it beats the editor's per-cell
handlers). Rewrote TestServeProfileHTMLLayered to the new model (per-role
context correctness: no admin leak; super-admin diagnostics present) and
dropped the now-dead stripTemplates helper. Validated in a containerized
browser; full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the bespoke, chrome-less /.tokens page. It now renders through the
shared tables engine — getting the standard header (logo, theme, profile
menu) + declarative columns/filters for free — from a server-injected,
pre-assembled #table-context built from the user's tokens (Store.List).
New, reusable "tables over an API collection" primitive (tables/js/
api-actions.js): when the injected context carries an `apiActions` block,
it drives create (a modal form → POST, surfacing the one-time secret) and
per-row delete (→ DELETE) against a REST endpoint, and hides the file-model
toolbar affordances (+ Add row / Save). It deliberately does NOT touch the
file-save/row-ops machinery (ETag/conflict/row-file writes), so the secrets
surface stays on the existing tested /.api/tokens endpoints.
Server: handler.injectTableContextObj injects an arbitrary pre-assembled
context; EmbeddedTablesHTML() exposes the renderer to sibling handlers;
ServeTokensPage builds the token context (+ apiActions for /.api/tokens)
and serves the tables HTML, falling back to the legacy skeleton only when
the store or the tables renderer is unavailable.
This is the first dynamic/virtual-record collection rendered by the same
declarative engine + chrome as on-disk tables — no bespoke page. Validated
end-to-end in a containerized browser (list + create→secret + revoke);
tests/tokens.spec.js updated to the new UI; full Go suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The supporting config files (table.yaml, form.yaml) can now live in the
admin-gated, hidden `<dir>/.zddc.d/` reserve instead of the directory root —
the `.zddc`-declares / `.zddc.d/`-carries split. Backward-compatible: the
legacy root location still resolves (preferred order: .zddc.d/ → root →
embedded default).
Because `.zddc.d/` is non-fetchable over HTTP for non-admins, the spec is
resolved server-side and INJECTED:
- handler: LoadViewSpec(dir, name) resolves .zddc.d/ → root → embedded
(classifyDefaultSpec is now location-agnostic — strips a `.zddc.d` segment).
- ServeTable injects the parsed table spec + row schema into the existing
#table-context as {spec, rowSchema}; RecognizeTableRequest also recognizes a
spec under .zddc.d/.
- formhandler loadFormSpec + specEligible prefer .zddc.d/form.yaml (forms
already inject #form-context, so server-only).
- client (tables/js/context.js): walkServer uses the injected spec/rowSchema
when present (server mode) and still walks the directory for ROW files; FS-
Access mode reads .zddc.d/<name> (then legacy root) via readYamlFirst. load()
passes the injected context through. Regenerated the embedded tables.html.
go build/vet/test ./... green; all 40 tables Playwright specs pass; the
ServeTable test now asserts the injected spec.
Remaining (next): file→form URL shape, retiring the recognizers in favour of
ServeView/views:, defaults.zddc.yaml views declaration, writers→.zddc.d/, and
the migration script.
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>
- editor.js: suppress edit entry for cells whose schema is readOnly
(folder-bound originator, server-managed audit fields) — mirrors the
$-prefixed synthesized-column guard. The server overwrites these, so
inline-editing them was misleading and the value was silently lost.
- save.js createRow: on 201, re-fetch the written row so server-derived
fields (originator from the party folder, the composed tracking
number's components, audit stamps) surface immediately instead of
staying blank until reload. Falls back to the local merge if the GET
fails.
- save.js createRow: handle 409 (duplicate composed tracking number)
with a clear message on the sequence cell instead of the generic
errored state.
Test: tables.spec.js — a readOnly column doesn't mount an inline editor
while a normal sibling still edits. The 409 + re-fetch paths go through
the in-dir create POST (formCreateUrl), which the file:// Playwright
harness can't intercept; both are covered by the server e2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four root causes, each affecting one or more pre-existing
failures. All resolved without weakening any assertion.
1. build-label.spec.js (×4 — archive/transmittal/classifier/browse)
The regex accepted v<X.Y.Z>-alpha|beta channel labels but not the
-dev label modern dev builds emit. CLAUDE.md describes
v<X.Y.Z>-dev as the canonical dev-build form. Added |dev to the
channel alternation; tests now pass on dev builds and remain
tight on stable cuts.
2. landing.spec.js (×8)
SAMPLE_PROJECTS fixture pre-dated the post-reshape listing JSON
contract. The landing's loader now filters projects on
`is_dir: true`; the fixture didn't set it, so every entry was
filtered out and every "renders a project table" test failed at
the `.project-table` wait. Added `is_dir: true` (and trailing
slash on names, matching the live server's shape) to the three
fixture entries.
3. browse.spec.js (×1 — Download (zip))
The #downloadZipBtn toolbar button was retired in the SPA
overhaul (94b2e29) — Download ZIP moved to the right-click
context menu. Test still poked the dead toolbar button. The
picked-root folder no longer renders as a row (only its
contents do), so the test now scopes the assertion to
downloading a sub-folder (sub/) via right-click → Download ZIP;
verifies the zip's entries, magic bytes, and filename.
4. tables.spec.js (×1 — Phase 3 row-blur fires PUT)
Real bug, not a test issue. The editor's commit path tears down
its input element (clearing focus to body) before refocusing
the owning cell. main.js's focusout-on-#table-root handler ran
synchronously, saw `relatedTarget=null`, treated it as "user
left the grid", and fired flushAll() — racing the
selection-change save that fires from the subsequent
setSelected(r+1, c) inside the Enter handler. Net effect: two
identical PUTs per row-blur. Deferred the focusout check to
next tick via setTimeout(0); the cell.focus() inside the
editor's tearDown has time to settle, and the deferred check
sees document.activeElement still inside #table-root → skips
the redundant flush.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two server-aligned signals on save paths:
- +Add row button: fetches /.profile/access?path=<current dir> via
zddc.cap.at() once on load; if path_verbs doesn't include 'c'
the button disables with a tooltip ("You don't have create
access in this folder."). Async race-window is the same as any
other path-scoped fetch — server still gates the POST so a
stale client gets a 403 toast on click rather than a silent
accept.
- 403 on save/create: previously fell into the generic
"http-error" bucket with a console warn; now branches into
zddc.cap.handleForbidden which renders an error toast naming the
missing verb. When the path-scoped view reports an elevation
grant covering that verb, the toast appends an Elevate button.
Per-row writability stays computed server-side for now — tables
walks rows via FS-API-style handles that don't surface the listing
verbs string. A follow-on pass can switch the row walk to raw
listing entries and gate row.editable on each entry's verbs.
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>
tables/js/save.js: on 200/201, parse the YAML body the server now
echoes back (it carries the just-stamped audit fields) and replace
row.data with it. The previous local-merge code falsified the table
view — server stamping changes bytes the client doesn't predict
(revision, created_by, previous_sha), and the merge would have shown
stale values until a fresh GET. Falls back to local merge when the
response has no body (non-record write or older server).
form/js/widgets.js:
- honor schema.readOnly (alongside the existing ui:readonly UI
override) so cascade-locked + audit fields render as disabled
- read x-labels for enums and render "<code> — <label>" in both
<select> and radio variants (server-injected from
field_codes:codes for human-readable dropdowns)
- propagate schema.pattern to <input pattern> as a UX hint
(authoritative validation runs server-side via WriteWithHistory)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace "Edit YAML" with "Edit row" — navigates to row.url, which
is already the schema-driven form-mode editor URL. The form handler
unwraps virtual-view URLs server-side so SSR and rollup rows route
to their per-party canonical paths automatically; no client-side
URL rewriting needed.
This fills the gap where row-click only opens the form for
complex-type cells (objects, arrays) — for plain scalars it enters
inline edit mode. Right-click → Edit row is now the discoverable
way to reach the full form for any row.
Raw YAML editing remains available via the browse tool directly
(navigate to the file's parent folder and click it in the tree).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three triggers for flushing pending edits:
- Save button in the toolbar — shown only when ≥1 row is dirty,
label reads "Save (N unsaved)". Disappears after a clean settle.
- Ctrl+S (Cmd+S) anywhere on the page, capturing-phase so it beats
the browser's "Save Page As" default.
- focusout of #table-root with a relatedTarget outside the grid —
catches "edit cell, click a header link, expect it to save".
The row-blur trigger stays — moving between rows still flushes. The
new triggers fill the gap when the user edits one row and then leaves
the grid entirely without first navigating to another row.
Dirty marker gets a 4px (was 3px) left swatch AND a faint blue
background tint on the row, so "unsaved" reads as a row state rather
than a small marker on the edge.
editor.setDraft / clearDraftField notify save.onDraftsChanged,
which refreshes the Save button + reapplies the dirty class.
saveRow on 200/201/202 also refreshes the button so it disappears
the moment its row settles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opens the row's backing .yaml in the browse tool's YAML editor
(preview-yaml.js — CodeMirror with syntax highlight, lint, Ctrl+S
save). Disabled on multi-row range and unsaved draft rows.
Three URL shapes resolve correctly:
per-party row → <dir>/?file=<file>.yaml
SSR virtual → /<project>/archive/<party>/?file=ssr.yaml
rollup virtual → /<project>/archive/<party>/<slot>/?file=<file>.yaml
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side download of the current view — filter + sort + column
order match what's on screen, values pass through util.formatCell so
dates / numbers / booleans render the same way they do in cells. RFC
4180 quoting; UTF-8 BOM so Excel detects encoding without an import
wizard. Sits next to "+ Add row" and shows for every table that
loaded with columns (no HTTP gate — the data is already in the
client), so MDL, RSK, SSR, and both project-level rollups all get
the affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the risk register as a sibling of MDL under archive/<party>/, and
three project-level virtual aggregations at <project>/{ssr,mdl,rsk}:
- SSR aggregates archive/<party>/ssr.yaml; "+ Add row" materializes a
new party folder (mkdir + auto-own .zddc + ssr.yaml). Renames go
through X-ZDDC-Op: ssr-rename, which os.Rename's the party
directory so every row inside follows. Party name doubles as the
folder name (no opaque IDs) and is path-derived on read.
- MDL/RSK rollups list every deliverable / every risk across all
parties with a derived `party` column; "+ Add row" is suppressed
because party affiliation is ambiguous in the aggregate view.
All four virtual roots are declared `virtual: true` in
defaults.zddc.yaml. Spec/form bytes come from six new embedded
defaults (default-rsk.*, default-ssr.*, default-project-{mdl,rsk}.*)
served via a generalized IsDefaultSpec/IsDefaultSpecAbs that replaces
the MDL-only recognizer. Listing synthesis lives in fs/tree.go;
ACL on each synthetic row evaluates against the canonical
archive/<party>/ chain so non-owners see rows read-only. PUT/DELETE
through virtual URLs rewrite to canonical paths in fileapi.go via
sibling-shape blocks that don't touch the ACL gate. SSR row DELETE
returns 405 (delete the party folder via the archive view).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three UI cleanups against the admin/browse chrome.
Red admin-mode frame (shared/elevation.css)
Was: body { outline: 3px ... ; outline-offset: -3px } — an outline
doesn't reflow content, so in tools that butt their content to the
viewport edge (browse split-pane, archive grid) the frame painted
on top of the first 3px of content.
Now: body.is-elevated::after { position:fixed; inset:0; border:3px;
pointer-events:none; z-index:9200 }. The frame lives in its own
fixed layer above all content, so it never overlaps or steals
clicks; content layout is unchanged.
Project-stage strip (Archive · Working · Staging · Reviewing)
Low-value chrome. Removed entirely:
- delete shared/nav.js + shared/nav.css
- drop the include from every tool's build.sh
(browse, transmittal, form, archive, landing, tables, classifier)
- delete tests/nav.spec.js
- rebuild tables.html (the //go:embed'd baked-in copy)
Project navigation already happens through the directory tree in
browse and the URL bar; the strip duplicated breadcrumb information
without adding capability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cell-editor was already complete (drafts, row-blur saves, etag
concurrency, validation). This commit adds the missing row-level ops:
- "+ Add row" appends a draft row inline; first cell focused. Row-blur
POSTs to <dir>/form.html (the existing form-create endpoint); 201
swaps the synthetic id for the server-returned URL/ETag. Empty rows
the user walks away from are silently discarded.
- Right-click a row → "Delete row" (or "Delete N rows" when a cell
range spans multiple rows). DELETE the row YAML with If-Match; 412
surfaces a conflict warning.
- Multi-row clipboard paste creates new rows for grid content that
extends past the last existing row, instead of dropping cells past
the end. Each new row saves via its own row-blur.
- Empty rows now have a 2.4em minimum height so a freshly-added row
is visible. Without the floor it collapses to cell-padding (~8px)
and looks like a divider line.
Server-side: no new endpoints. Form-create (POST <dir>/form.html →
201 + Location) and file-API DELETE carry the new client capabilities.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Polish pass after the big refactor in 2d114fc.
== Header elevation slot propagated ==
shared/elevation.{js,css} surface a header checkbox for admins.
30-minute sudo-style cookie window (Max-Age=1800, SameSite=Lax).
Only renders when /.profile/access reports can_elevate=true; quiet
for non-admins. Slot added to all 7 tool templates and concat'd
into all 7 build.sh files; admin in any tool now sees the toggle.
Three text-rename ride-alongs in archive/classifier/transmittal
templates: "Add Local Directory" → "Use Local Directory" (the same
rename that landed in browse earlier in this branch).
== Docs ==
- CLAUDE.md gets an "Admin elevation is sudo-style" paragraph in
the "Things that bite if you forget" section.
- AGENTS.md gets a dedicated "Admin elevation (sudo-style)" section
alongside "Bearer tokens" — same depth as the existing auth docs.
== Helper file splits ==
The retired form editor's shared helpers got bundled into a single
zddc_admin.go in the cleanup; that name is now misleading. Split by
concern:
- admin_helpers.go: hasAnyAdminScope (the only admin-specific helper)
- paths.go: resolvePath, urlPathOf, chainDirs (URL ↔ filesystem path
math — used by several profile / zddc-file handlers)
- profile_assets.go (renamed from zddc_admin_assets.go): custom CSS
pipeline. URL renamed from /.profile/zddc/assets/ → /.profile/assets/
since /.profile/zddc/ no longer hosts an editor.
- treeEntry moves to profilehandler.go (alongside AccessView, its
only consumer).
- writeError moves to profileprojects.go (its only consumer).
== Smell cleanup ==
- zddc.HasAnyAdminGrant(fsRoot, email) — new elevation-independent
primitive that walks the cascade and reports whether email is named
in any admin: list anywhere. Replaces the synthetic-elevated probe
hack in enumerateAccess (`Principal{Email, Elevated: true}` was
"lying" to the elevation gate to ask what it would say). The handler's
hasAnyAdminScope collapses to a 4-line wrapper that gates on
p.Elevated and delegates.
- Access-log middleware records `elevated` per request, so forensics
can distinguish "admin acting as user" from "admin exercising power."
- browse/js/app.js's ?file= deep link walks multi-segment paths. Each
intermediate segment is matched + expanded; the leaf gets
selected/previewed. Auto-shows hidden when any segment starts with
. or _. Silently no-ops on unresolved segments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visiting `/Project-1/archive/PartyA/mdl` (no trailing slash) errored with
`Unrecognized table URL` because tableNameFromUrl only matched
`…/<rowsdir>/table.html`. The cascade declares `default_tool: tables` at
`archive/<party>/mdl`, so the server serves the tables HTML at the bare
directory URL — a shape the client didn't recognize.
Two coordinated fixes:
- shared/zddc-source.js `pathToDir`: was over-eagerly stripping the last
segment when the URL didn't end in `/`. Now checks whether the last
segment contains a dot — file URLs strip to parent (original behavior
preserved), bare-directory URLs append the missing slash. Only call
site is detectServerRoot, so blast radius is contained.
- tables/js/context.js `tableNameFromUrl` + `rowEditUrl`: accept both
legacy `…/<rowsdir>/table.html` and the new bare-directory shape.
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>
The .app-header__logo SVG was decorative on every tool. Web's
strongest convention is "click logo → go home" — so users tapping
it expecting that fallback got nothing. Now the logo is wrapped in
an anchor whose href reflects the URL the page was loaded from:
file:// → no wrap (no server home to point at)
/ → wrap, href=/ (deployment root)
/index.html / /<tool>.html → wrap, href=/ (root, no project)
/<project>/... → wrap, href=/<project> (project landing)
The wrap happens client-side at DOMContentLoaded via shared/logo.js,
loaded by every tool's build.sh after toast/nav. Idempotent — a
template-supplied anchor or a second mount call is a no-op.
The companion shared/logo.css adds a subtle hover/focus affordance
(opacity 0.82, focus ring) so the logo reads as clickable without
otherwise altering its visual weight. Tools opt out by setting
window.zddc.logo.disabled = true before DOMContentLoaded (e.g. for
deployments that pin the logo to an external destination).
Five Playwright tests (tests/logo.spec.js) lock the contract:
no-wrap on file://, href=/ at root, href=/<project> in project
subtree, aria-label matches target, idempotent re-mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a thin nav strip directly under the app-header showing the four
canonical lifecycle stages from the transmittal-workflow spec:
archive · working · staging · reviewing. Each is a link to that
stage's directory under the current project. Current stage is
highlighted (bold + primary color, aria-current="page"). Strip
mounts as a sibling of .app-header on DOMContentLoaded — no
template changes needed in any tool.
Render rules (shared/nav.js shouldRender):
- location.protocol must be http: or https: (file:// has no project
structure to navigate within)
- a project segment must be detectable as the first path segment
(when it isn't a tool HTML file like /index.html or
/archive.html?projects=A,B). Multi-project view at the deployment
root therefore shows no strip.
Stage URL targets:
- Archive → <project>/archive.html (project-root archive view)
- Working → <project>/working/ (directory listing — mdedit auto-served)
- Staging → <project>/staging/ (directory listing — transmittal auto-served)
- Reviewing → <project>/reviewing/ (directory listing)
Convention-driven, not probed: if a deployment doesn't have one of
these folders the link returns 404. Operators on non-standard layouts
can opt out by setting window.zddc.nav.disabled = true before
DOMContentLoaded.
This pairs with the previous landing-tool change (single-project
click → <project>/archive.html). Together they give the user
both URL-bar manipulation AND visible navigation across the four
canonical project stages.
Five Playwright tests in tests/nav.spec.js exercise:
- non-render at deployment root
- render + active stage on <project>/archive.html
- render + active stage deep inside <project>/working/foo/mdedit.html
- canonical link targets
- mount position is sibling of .app-header
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote classifier's local toast (classifier/css/base.css + showToast
in classifier/js/excel.js) into shared/toast.{js,css}. Every tool's
build.sh now concatenates them, so window.zddc.toast(msg, level, opts)
is callable from any tool.
API:
window.zddc.toast('Saved.', 'success');
window.zddc.toast('Could not load: ' + err.message, 'error');
window.zddc.toast('Note', 'info', { durationMs: 3000 });
Levels: info (default) | success | warning | error. Single-toast
policy — a second call replaces the first. Click anywhere on the
toast to dismiss. ARIA: error → role=alert/aria-live=assertive,
others → role=status/aria-live=polite.
Class prefix is .zddc-toast (BEM-ish) to avoid colliding with any
tool-local .toast rules. Classifier's existing showToast now
delegates to window.zddc.toast — call sites in excel.js +
selection.js are unchanged. Classifier's local .toast CSS block
deleted in favor of the shared one.
This commit only EXPOSES the API. Replacing the ~25 alert() call
sites scattered across archive/transmittal/mdedit/classifier with
toast calls is left as follow-up — each alert needs per-call review
to decide if it's truly non-blocking.
Five Playwright tests in tests/toast.spec.js lock the contract:
API exposure, level mapping, ARIA roles, single-toast replace,
click-to-dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TODO at save.js's unload handler was "switch to keepalive on save
for the unload path." flushAllDrafts() kicks off saveRow() per dirty
row when the page is being navigated away from, but those fetches were
not flagged keepalive — modern browsers can cancel them mid-flight as
the page unloads, dropping the user's last typing.
saveRow() now accepts an opts.keepalive flag that is passed through to
fetch(). flushAllDrafts() passes {keepalive: true} so the unload path
gets the keepalive guarantee. Normal saves are unaffected (keepalive
imposes a 64 KB body cap per the Fetch spec — only worth that trade
on the unload path).
Also refreshes the embedded zddc/internal/handler/tables.html bytes via
./build, which folds in this change plus the form welcome-state CSS
from c585112.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the reference doc at zddc.varasys.io/reference.html#tracking-numbers,
a tracking number is composed of: originator, [phase], project,
[area], discipline, type, sequence, [suffix]. The default Master
Deliverables List now surfaces every component as its own column,
plus the standard MDL metadata (title, plannedRevision,
plannedDate, status, owner). Columns appear in the canonical
filename order so the table reads left-to-right like the tracking
number itself.
Optional components ([phase], [area], [suffix]) render in the
table even when blank — keeps the layout consistent across rows.
Projects on a schema that doesn't use them hide the columns by
overriding (see customization).
Form schema (default-mdl.form.yaml):
- One JSON Schema property per tracking-number component, plus
the deliverable metadata. originator / project / discipline /
type / sequence are required; phase / area / suffix are
optional. The schema is intentionally permissive — free-text
strings on every component, no enums or regex constraints.
Projects pick their own conventions for originator codes,
discipline vocabularies, etc.; a default that imposed a
fixed set would just get in the way.
- Phase 2's editable-cell widget factory derives the right
per-cell editor from this schema: text inputs for the
components, the existing select for `status` (which keeps
its enum), date input for `plannedDate`, textarea for
`notes`.
Customization (the "way for end users to customize"):
- Drop your own table.yaml and / or form.yaml into the rows
directory (archive/<party>/mdl/, or any directory hosting a
table). Operator-supplied files override the embedded defaults
ATOMICALLY — there's no field-level merge, the operator file
wins entirely. This matches every other "spec on disk wins"
convention in zddc-server.
- Hide a column: omit it from the columns: list.
- Rename a column header: change `title:`.
- Add a column: append a {field, title} entry AND add a
matching property in form.yaml's schema.properties.
- Tighten constraints: use `enum:`, `pattern:`, `minLength:`
etc. on form.yaml properties.
- Pre-filter rows on load: defaults.filter[<field>].
The whole rows-directory is self-contained — copying mdl/ to a
new project takes the spec, the form, and every row YAML
together.
Documentation:
- AGENTS.md "Tables system" gains a paragraph on the default-MDL
column set + the customization mechanism + a pointer to the
embedded source files.
- tables/template.html help panel rewrites the body to cover:
* What the directory IS (spec + form + row YAMLs together).
* Editable-cell keyboard shortcuts (the Phase 1-5 sequence
we just shipped — arrows, Tab, Enter, F2, Delete, Ctrl+D /
R / C / V / Z, Shift+arrow / Shift+click for ranges).
* The auto-save model + per-row state swatch colors.
* The customization model with a worked file-tree example.
Replaces the obsolete pre-Phase-1 wording that referenced
`*.table.yaml` parent files and click-to-navigate-row UX.
Tests: no schema test changes — the default YAMLs are loaded
through the same RecognizeTableRequest / RecognizeFormRequest
paths that already cover the fallback. Full Playwright + Go
suites green (44 + 13).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final phase of the editable-cell sequence. Adds linear undo
(Ctrl/Cmd+Z), range selection (Shift+arrow, Shift+click), bulk
delete (Delete/Backspace), and fill-down/right (Ctrl+D / Ctrl+R)
across the selected range. Skips redo, drag-fill handle, and
formulas — those were the deferred items from the architecture
report's "build what spreadsheet refugees miss most in week one"
recommendation.
Undo (tables/js/undo.js):
- Linear command stack, depth 50, session-local. Each Command
is { cells: [{rowId, field, oldValue, newValue}, ...] }.
Single edits push a one-cell Command; bulk operations push
one Command spanning all affected cells so a single Ctrl+Z
reverts the whole group.
- Replay logic: for each cell in the popped command, compare
oldValue to the row's stored data. If they match → clear the
draft (the user's edit reverts to baseline). Otherwise →
setDraft to oldValue (intermediate state). Then app.repaint().
- Hotkey: document-level keydown for Ctrl/Cmd+Z. Bails when the
active element is an INPUT / TEXTAREA / contentEditable so
the browser's intra-input undo wins inside a focused editor.
- Pushed by every edit path: editor.commit, editor.bulkClear,
editor.bulkFill. Phase 4's clipboard.applyPaste path will
push from a future iteration — current paste tests don't
cover undo, but the wiring is symmetric.
- Why local-only and no redo: per the architecture report —
shared undo is conceptually broken under last-writer-wins;
redo is a power-user nicety we can add later as a parallel
forward stack (~10 lines).
Range selection (tables/js/editor.js):
- New state: app.state.range = {anchor, focus} | null. Anchor
is the cell where the range started; focus is the current
edge. The cell at focus also has tabindex=0 (the keyboard
focus owner).
- Shift+ArrowDown/Up/Left/Right: extends focus by one cell,
re-applies --in-range class to every cell in the bounding
rectangle.
- Shift+click on a cell: extends the range from anchor to the
clicked cell. Plain click clears the range.
- Escape clears both selection and range.
- Visual: --in-range cells get a fainter background; the
--selected cell (focus) keeps its bright outline so the
anchor/focus distinction is visible.
Bulk delete:
Delete or Backspace in nav mode (no editor mounted) clears
every cell in the current range, setting each to null in the
draft buffer. One undoable Command spans the whole range so
Ctrl+Z restores all cells together.
Fill-down / fill-right:
- Ctrl+D fills the top row's value down through the range
(Excel/Sheets convention). Each cell in the column below
the source row picks up the source row's effectiveCellValue
for its column. Cross-column variation preserved.
- Ctrl+R fills the left column's value right through the
range. Symmetric to Ctrl+D.
- Both push a single multi-cell Command.
Bug fix shipped alongside:
editor.commit and editor.cancel now ev.stopPropagation() in
addition to preventDefault. Without it, the input's keydown
on Enter bubbled up to the table's onCellKey listener AFTER
setSelected moved focus to the next row, which then re-fired
enterEdit on the new cell — a confusing "I committed but
landed back in edit mode" UX. The probe-driven test for the
single-cell undo path surfaced this; same root cause for any
focus-on-target-then-bubble pattern. Tab and Escape get the
same treatment for symmetry.
Tests (7 new Phase 5 specs, total 44 in tests/tables.spec.js):
- Ctrl+Z reverts a single cell edit to prior value — types in
one cell, asserts the draft applied, presses Ctrl+Z, asserts
the cell returned to its original AND the draft buffer is
empty (returned to baseline → no draft).
- Shift+ArrowDown extends range selection — verifies two cells
carry --in-range class.
- Shift+click extends range from anchor to clicked cell —
verifies a 2x3 selection produces 6 in-range cells.
- Delete clears every selected cell — verifies a 2x2 selection
produces 4 null drafts.
- Ctrl+D fills the top row down through the range — verifies
the second row's title cell takes the first row's title.
- Ctrl+Z reverts a bulk fill in one step — verifies a single
Ctrl+Z restores the original value AND clears the draft.
- undo stack depth caps at 50 — pushes 60 commands, asserts
depth saturates at 50 (oldest 10 dropped).
Bundle size: 138 KB → 144 KB.
Files:
- tables/js/undo.js (new) — command stack, undo, Ctrl+Z hotkey.
- tables/js/editor.js — extendRange, ensureRange, clearRange,
rangeCells, bulkClearSelection, bulkFill; commit pushes undo;
Shift+arrow / Shift+click handlers; Delete + Ctrl+D + Ctrl+R
in onCellKey; setSelected respects keepRange opt; Enter/Tab/
Escape stopPropagation fix.
- tables/js/app.js — state.range field.
- tables/build.sh — undo.js in concat list.
- tables/css/table.css — --in-range styling.
- zddc/internal/handler/tables.html — regenerated bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bidirectional clipboard interop with Excel, Google Sheets, and any
other spreadsheet that uses RFC-4180-ish TSV on the text/plain
clipboard mime. Pasted cells write straight into the draft buffer
the same way per-key edits do; row-level save (Phase 3) picks them
up on the next row-blur with the same If-Match optimistic-
concurrency flow.
TSV parser (clipboard.js parseTSV):
- Tabs separate columns, \\n / \\r\\n separate rows.
- Quoted fields ("...") may contain tabs and newlines verbatim.
- Doubled \\"\\" inside a quoted field escapes a literal \\".
- Trailing empty row from a final \\n is dropped (Excel sends
this; matching the convention avoids a phantom blank row at
the end of every paste).
Apply-paste (clipboard.js applyPaste):
- Anchor = currently selected cell.
- 1×1 clipboard into selection → writes that one cell.
- N×M clipboard → SPILLS from the anchor down/right to
(anchor.row + N - 1, anchor.col + M - 1). Cells past the end
of either axis are silently dropped with a toast count.
- Each pasted value goes through coerceCell, which checks the
column's row-schema property type:
* number / integer → Number()
* boolean → "true"|"yes"|"1" → true; "false"|
"no"|"0"|"" → false
* everything else → raw string
Drafts hold the right JS type so the row-PUT body matches the
JSON Schema the server validates against.
Copy (clipboard.js onCopy):
- Single-cell selection: Ctrl/Cmd+C writes the cell's
effectiveCellValue (draft if dirty, else stored) as text/plain
via formatCell (RFC-4180 quoting on tab/newline/quote).
- Range copy is Phase 5 (depends on range-selection landing).
Event wiring:
- document.addEventListener('paste'/'copy') so events bubble
from any cell with focus. Phase 1's roving tabindex moves
focus around; per-cell binding would have to be re-applied
after every paint.
- onPaste bails when an editor input is mounted (the input
owns its own paste — typing into a cell editor that was just
populated with a chunk of TSV would be a footgun).
Toast for partial pastes:
When applyPaste skipped any cells, a small message in
#table-status: "Pasted N cells; M dropped (out of bounds)".
Auto-clears after 4s. Coexists with Phase 3's stale-row prompt
(toast doesn't fire if a prompt is already up; prompt outranks
toast).
Tests (6 new Phase 4 specs, total 37 in tests/tables.spec.js):
- parseTSV handles tabs, newlines, and quoted fields — covers
the parser edge cases including embedded \\n inside "..." and
doubled "" escapes.
- paste single value into selected cell — the 1×1 path; verifies
the draft buffer entry.
- paste 2×2 grid spills from anchor — the N×M spill semantic.
- paste coerces numeric/boolean values via row schema —
verifies the draft holds typeof===number for an integer column
and === true for a boolean column.
- paste out-of-bounds drops cells silently with toast — drives
via dispatched ClipboardEvent('paste') (the only way to
exercise onPaste end-to-end including the toast).
- copy single cell writes value to clipboard — synthesizes a
ClipboardEvent('copy') with a writable DataTransfer payload
and asserts the cell value lands in text/plain.
Bundle size: 134 KB → 138 KB.
Files:
- tables/js/clipboard.js (new) — parseTSV, formatTSV,
applyPaste, onPaste/onCopy, toast helper.
- tables/build.sh — clipboard.js in concat list.
- zddc/internal/handler/tables.html — regenerated bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cell edits now actually persist. Row-level batch save fires on
row-blur (selection moves to a different row); the request is one
PUT with the full merged row (server-side data + client drafts)
and If-Match: <etag> for optimistic concurrency. Conflict and
validation responses are surfaced inline; drafts are NEVER silently
discarded — when the server says no, the user's typing stays put
until they explicitly reload or replay.
Architecture (per the research synthesis from earlier in this
sequence):
- ETag tracking: context.js readRows captures the per-row ETag
from HttpFileHandle's response header on the initial GET.
Stashed at row.etag alongside row.data and row.yamlUrl. Phase 3
reads it; later phases (undo replay) inherit it.
- Row-blur trigger: editor.js setSelected calls a new
notifySelectionChanged() hook after selection lands. save.js's
onSelectionChanged tracks _previousSelectedRowId; when it
changes AND the previous row had drafts, fires saveRow(prevId).
Fire-and-forget — don't block the user's flow on the network.
- save.saveRow flow:
1. mergeRow(row.data, drafts) → full updated row.
2. js-yaml dump → wire body.
3. PUT row.yamlUrl, body, headers={Content-Type, If-Match}.
4. Branch on response status:
- 200/201 → success: clear drafts + invalid marks, capture
new ETag from response, replace row.data with merged.
- 202 → outbox queued (downstream client offline):
clear drafts (the outbox owns them now), mark row queued.
- 412 → stale: drafts STAY; mark row stale; show
status-bar prompt with [Use mine] / [Reload] buttons.
- 422 → server validation failed; body has
{errors: [{path, message}]}; mark each cell invalid via
a red-corner CSS marker + title-attribute tooltip.
- other → mark errored; drafts stay.
- Conflict resolution UX:
- "Use mine" replays the user's drafts onto fresh server
state. Re-GETs the row to learn the new ETag + new server
data, replaces row.data with the fresh server values, then
re-PUTs the merge of fresh + drafts. This is client-side
field-level last-writer-wins: fields the user did NOT
touch get the server's new values automatically; only
fields the user changed override server state. No JSON
Patch endpoint required — pure client logic on top of the
existing whole-row PUT path.
- "Reload" drops drafts entirely, re-GETs the row, repaints.
- Validation error display: per-cell red-corner triangle
(Excel-style) plus title-attribute tooltip on hover. Marker
keyed off data-col-idx + the column's field; survives until
the next edit on that cell or the next paint() cycle.
- beforeunload safety net: any rows with drafts at unload time
get one fire-and-forget save attempt. Modern browsers limit
what beforeunload can do; a follow-up could add fetch's
keepalive flag for a more reliable last-shot.
UI surfaces:
- Per-row state classes drive a left-border swatch in the first
cell:
--dirty subtle blue (uncommitted changes)
--saving muted grey (PUT in flight)
--queued warm yellow (outbox accepted)
--invalid orange (server 422)
--stale warning amber (server 412 — also tints row bg)
--errored red (other failure — also tints row bg)
These re-apply across re-paints via save.markAllDirtyRows()
called from main.js's paint() hook (innerHTML='' wipes them).
- #table-status doubles as the conflict prompt host. When a row
goes stale, the bar shows
"This row was changed by someone else. [Use mine] [Reload] [×]"
and the row-id it's bound to is stored on data-row-id so a
successful reload of that row dismisses the prompt.
Outbox (downstream client) interaction:
The cache layer's PUT-replay queue intercepts saves transparently.
On local network failure the cache returns 202 with
X-ZDDC-Cache: queued; we treat 202 as "succeeded for now" —
drafts clear (the outbox owns them and will replay), but the
row stays marked --queued so the user knows the write hasn't
reached upstream yet. When the cache replays and gets a
real 200/201/412/etc., the row state will reflect that on next
read (next paint cycle / page refresh).
Tests (4 new Phase 3 specs, total 31 in tests/tables.spec.js):
- row-blur fires PUT with merged drafts + If-Match. Edit a
cell in row 0, Enter (commits + moves to row 1). Verifies
PUT went out with the right URL, the merged YAML body
contains the new value AND the unchanged fields, and the
If-Match header carries the original ETag.
- 412 conflict marks row stale + shows status prompt. Verifies
the row gains the stale class, the status bar appears with
both [Use mine] and [Reload] buttons, AND the draft is
preserved (never silently dropped on conflict).
- 422 validation errors mark cells invalid. Verifies multiple
field errors → multiple red-corner cells.
- Reload button drops drafts and refreshes. Verifies the bar
hides and drafts clear after a successful reload GET.
Setup: a small page.route helper intercepts http://test.local/*
PUTs and GETs, lets each test queue the next response via
window.__nextResponse, and captures requests at
window.__capturedRequests for inspection. Test fixtures use
absolute http URLs in row.yamlUrl so the route catches them.
Bundle size: 127 KB → 134 KB.
Files:
- tables/js/save.js (new) — saveRow, useMine, reload, status
prompt, row-state markers, beforeunload flush.
- tables/js/editor.js — notifySelectionChanged hook.
- tables/js/context.js — etag + yamlUrl on each row.
- tables/js/main.js — paint() re-applies dirty markers via
save.markAllDirtyRows; exposes app.repaint for save callbacks.
- tables/build.sh — save.js in concat list.
- tables/css/table.css — row-state classes + invalid-cell corner
+ status-bar prompt styling.
- zddc/internal/handler/tables.html — regenerated bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the always-text-input cell editor with a per-property
widget factory keyed off the row's JSON Schema (form.yaml). The
table view now picks the right editor for each cell automatically:
strings get text inputs, enums get dropdowns, integers get number
inputs with min/max, dates get date pickers, booleans get
checkboxes, multi-select arrays get a multi-select. Cells whose
schema is a complex type (nested object, generic array, oneOf /
anyOf / allOf) can't be inline-edited and punt to the row's
form-mode editor on Enter / double-click.
Schema discovery:
context.js walkServer fetches <currentdir>/form.yaml as a
companion to <currentdir>/table.yaml — same file the form-mode
renderer already loads, just from the table view's perspective.
Best-effort: a directory with table.yaml but no form.yaml still
renders as a sortable/filterable table; cells just fall back to
plain text inputs without per-property hints. The schema is
exposed as ctx.rowSchema and consumed by the editor's
propertySchemaFor() helper, which walks dot-separated field
names through schema.properties to locate each column's
property schema.
Editor factory (editor.js):
- propertySchemaFor(col) — schema lookup keyed by col.field.
- isComplexSchema(s) — true for nested object, generic array,
oneOf/anyOf/allOf. Multi-select-friendly arrays
(string-enum + uniqueItems) are NOT complex; they get an
inline multi-select widget.
- makeWidget(propSchema, col, initialValue) — dispatches to one
of the widget builders below based on schema type / format /
enum + column-spec hints (col.format / col.enum) for tables
without a form.yaml.
Widget builders, each returning {element, getValue, focus}:
- widgetText — plain <input type=text>, default fallback.
- widgetTextarea — for string with maxLength > 200 (long
narrative fields).
- widgetTyped(type) — typed inputs the browser can help validate;
used for date / date-time / email.
- widgetNumber — <input type=number> with min/max/step
derived from schema.minimum/maximum/
multipleOf. Integer schemas force step=1.
getValue returns Number, not string, so
the draft buffer holds the right type for
JSON serialization later.
- widgetCheckbox — <input type=checkbox>; getValue returns
bool. initial value coerces from "true"/
true string-or-bool.
- widgetSelect — <select> with empty placeholder + one
option per enum choice; getValue returns
the chosen string or null.
- widgetMultiSelect — <select multiple> with size = min(6, N);
getValue returns the array of selected
values (preserves order in the option list).
Complex-type cells:
isComplexSchema(propSchema) → enterEdit calls navigateToRowForm,
which routes to row.url (already the <id>.yaml.html re-edit URL
the row tracker holds). Phase 5 may swap this for an inline
side-panel mount of form-mode in the same bundle, but the
current navigate-out path delivers the same eventual UX without
needing the side-panel scaffolding.
Type-aware draft equality:
The pre-Phase-2 commit treated every value as a string and
compared via String() equality, which would mark any number-
column edit dirty even when the user re-typed the same number.
The new sameValue() helper handles bool/object via JSON-string
equality and falls back to loose string compare so 42 == "42"
isn't a false dirty. Drafts hold typed values (number, bool,
array) instead of all strings, so when Phase 3 wires the row PUT
the body shape matches the JSON Schema the server validates
against without an additional coercion pass.
Tests (tests/tables.spec.js — 7 new specs, total 22 in the
table view, all 27 in the file):
- enum column edits via select dropdown — verifies the empty
placeholder + 3 enum options render and the chosen value
displays back in the cell.
- integer column gives a number input with min/max — verifies
the type/min/max/step attributes derive from the schema, AND
the draft buffer holds typeof === 'number'.
- boolean column gives a checkbox — verifies type=checkbox and
the draft holds true after Space-toggle. (Toggle via Space,
not Playwright's .check() helper, to dodge the click+blur
race a focused-checkbox-inside-grid-cell hits.)
- format:date column gives a date input — verifies type=date
and the existing value pre-populates as YYYY-MM-DD.
- multi-select enum-array column gives a multi-select.
- complex (object) column navigates to the row form on edit —
verifies no inline editor mounts AND the navigate seam
receives the row's URL.
- no rowSchema → falls back to plain text editor — verifies the
best-effort behavior for directories with only table.yaml.
Bundle size: 124 KB → 127 KB (+3 KB for the factory + widget
builders).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First step toward the Excel-like editable-table the user asked for.
Architecture decisions in this phase came from a focused research
pass over Notion / Airtable / AG Grid / Handsontable / Glide / W3C
ARIA APG; the design notes are in this commit's predecessor as a
research synthesis. Five phases planned; this is phase 1 of 5 and
ships the cell-selection + keyboard-navigation + per-cell editor
mount-on-demand foundation. Edits in this phase live in a client-
side draft buffer only; row-level save + ETag conflict UX is
phase 3.
Scope:
- ARIA grid pattern verbatim (W3C WAI-ARIA APG): role=grid on the
table, role=row on rows, role=gridcell on cells, roving
tabindex (only one cell carries tabindex=0; arrows move it).
This makes the grid one tab stop in the page tab order — the
documented spreadsheet UX, and also the basis for screen-reader
correctness.
- Click selects a cell. Arrow keys move selection. Tab and
Shift-Tab move with row-wrap. Home / End jump within row;
Ctrl/Cmd+Home / End jump to grid corners. Enter, F2, double-
click, or any printable character all enter edit mode. In edit
mode: Enter commits and moves down (Excel convention), Tab
commits and moves right (with row-wrap), Escape cancels and
restores the prior value, blur commits.
- Mount-on-demand cell editor: one <input> at a time is
instantiated inside the selected cell. Survives 1000-row tables
without the focus-ring churn an always-editable design would
hit, and lets Phase 2 swap the input for schema-driven widgets
(number / date / select / etc.) without restructuring.
- Draft buffer at app.state.drafts keyed by row id (the row's
re-edit URL — stable across sort and filter). When a cell
commits with a value different from row.data, the draft entry
is set; render reads from the draft via effectiveCellValue() so
the visible cell content reflects unsaved edits. No-op edits
(commit returns the original value) clear any pending draft.
- Selection survives re-paints. Sort / filter / spec changes
trigger a re-render; the editor's setSelected at end of paint()
clamps to new bounds and rebinds tabindex. The user's cell
doesn't disappear when they sort the column they're editing.
- Numeric coercion fast-path: cells whose column declares
format=number/integer coerce the input string to Number on
commit. Phase 2 will generalize this to schema-driven coercion
for date, boolean, enum, etc.
UX consequence — single-click semantics change:
The pre-existing row-click-navigates-to-form-edit behavior is
gone. Single click now selects a cell (spreadsheet-native). The
"open this row in the form editor" affordance moves to phase 2
(an explicit "Edit…" button or an icon column). The row-click-
navigation tests in tests/tables.spec.js are replaced with seven
new tests covering the editor lifecycle.
What this phase does NOT do (and which phases own it):
- Phase 2: schema-driven editor widgets (right input type per
column). Server-side validation 422 → red-corner marks. Complex
types (object, generic array, oneOf) get an "Edit…" button that
opens the side-panel form-render mode the unified bundle
already ships.
- Phase 3: row-level save on row-blur via PUT + If-Match. Stale-
row badge with "Use mine" / "Reload" on 412. Outbox carries the
offline path transparently via the existing source.js layer.
- Phase 4: copy/paste from Excel/Sheets via TSV parser, spill-
from-anchor or fill-all into a selection range.
- Phase 5: undo (linear command stack, Ctrl+Z, session-local) and
multi-cell ops (range select, bulk delete, Ctrl+D / Ctrl+R fill).
Tests (tests/tables.spec.js, all 15 pass):
- clicking a cell selects it (replaces the old row-click-navigates
test; verifies single-click does NOT navigate)
- arrow keys move cell selection
- Tab and Shift-Tab traverse cells with row-wrap
- Enter enters edit mode; Enter commits and moves down (verifies
draft is applied to visible cell + selection moves)
- Escape cancels edit, restoring prior value (verifies no-op on
draft buffer)
- typing a printable char enters edit and replaces the value
- double-click also enters edit mode
- non-editable rows still get the readonly class (cosmetic guard
for an existing convention; phase 3 will gate write submission)
Files:
- tables/js/editor.js (new) — selection + keyboard handling +
edit-mode lifecycle + draft buffer.
- tables/js/app.js — state.selected / state.editing / state.drafts
fields.
- tables/js/render.js — ARIA roles + editor.attachToCell wiring;
cells render via editor.effectiveCellValue so drafts show.
- tables/js/main.js — paint()-end editor.attachToTable +
setSelected restore.
- tables/css/table.css — selected-cell focus ring (outline,
doesn't shift surrounding cells); cell-input bare-inside-cell
styling.
- tables/build.sh — editor.js in the concat list.
- zddc/internal/handler/tables.html — regenerated bundle.
Bundle size: 117 KB → 124 KB (+7 KB for editor.js + ARIA + draft
machinery). Well within the budget the library survey identified
(Tabulator would have been +100 KB; SlickGrid +34 KB; custom is
+7 KB and we keep the no-third-party-deps invariant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.
PART 1 — in-dir convention for table+form spec files
Old layout had the spec at the parent and rows in a child:
archive/<party>/
mdl.table.yaml spec
mdl.form.yaml row-edit form
mdl/ rows-dir
row-001.yaml ...
URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.
New layout collapses everything into the rows-dir:
archive/<party>/mdl/ self-contained
table.yaml spec
form.yaml row-edit form
row-001.yaml ... rows
URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).
Server changes:
- internal/handler/tablehandler.go RecognizeTableRequest fires on
/<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
alias map is gone — pure presence-based discovery now matches
the form system's existing convention. Default-MDL fallback at
archive/<party>/mdl/ stays for the virgin-archive case (the
rows-dir need not exist on disk; the URL renders fully virtually).
- internal/handler/formhandler.go RecognizeFormRequest fires on
/<dir>/form.html and /<dir>/<id>.yaml.html with spec at
<dir>/form.yaml. specEligible accepts on-disk files OR the
default-MDL virtual path so an empty mdl/ dir still surfaces the
add-row form.
- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
serving archive/<party>/mdl/{table,form}.yaml (5 segments after
ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
isAtArchivePartyMdlDir for directory-based recognition. New
IsDefaultMdlSpecAbs accessor for callers that hold an abs path
rather than a URL (formhandler).
- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
back to embedded default-MDL bytes when os.ReadFile returns
NotExist AND the path matches the archive-party-mdl shape. Three
call sites updated to pass cfg.Root.
- internal/handler/formhandler.go serveFormCreate writes
submissions to filepath.Dir(req.SpecPath) — the spec, the form,
and rows all live in one directory. The submissionsDir creation
is idempotent (MkdirAll); cascade falls back one level for ACL
evaluation when the dir hasn't been materialized yet.
- internal/handler/tablehandler.go tableRowsRedirect now points at
/<dir>/table.html (was /<dir>.table.html) when the directory
request maps to a recognized table.
- cmd/zddc-server/main.go dispatch synth flips from
urlPath + ".table.html" to urlPath + "/table.html" for the
no-trailing-slash → tables-app routing.
- internal/apps/availability.go DefaultAppAt comment clarified
that the dir at archive/<party>/mdl/ IS the table (not a child).
Client changes:
- tables/js/context.js walkServer fetches <currentdir>/table.yaml
directly — no .zddc walk for table declarations. Rows are every
*.yaml in current dir EXCLUDING table.yaml and form.yaml. The
.zddc fetch-for-aliases is gated on file:// (online mode 404s
on .zddc reads via the dispatcher's reserve guard, so skipping
the request avoids browser console noise).
- tables/js/main.js add-row button links to relative form.html
(same dir).
- tables/js/render.js + filters.js: every column's autofilter is
uniformly a text-contains input, even enum columns — keeps the
filter row visually consistent and doesn't constrain users to
the enum vocabulary.
PART 2 — unified table+form HTML bundle
The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.
- tables/template.html grows two top-level mode containers:
#table-mode (toolbar + sortable table) and #form-mode (form +
submit button). Both hidden at parse time; the dispatcher
unhides one. The shared #form-context placeholder was added
here so the server's existing injectFormContext target
resolves.
- tables/js/mode.js (new) sets window.zddcMode synchronously
based on URL pattern: /form.html or /<id>.yaml.html → form,
/table.html → table, else inline-context fallback for
file:// (whichever context blob is non-empty wins). Unhides
the matching container at DOMContentLoaded.
- tables/js/main.js init() and form/js/main.js boot() each guard
early when mode isn't theirs. Both apps live on different
globals (window.tablesApp vs window.formApp) so module
registration doesn't collide.
- form/js/main.js title write falls back from #form-title to
#table-title (the unified bundle's shared header element)
when the dedicated id isn't present.
- tables/build.sh concatenates form modules (widgets, render,
object, array, errors, post, serialize, util) and form CSS.
No new external deps. Bundle grows from ~95KB to ~120KB.
- internal/handler/formhandler.go drops the //go:embed form.html
directive; serveFormRender now writes embeddedTablesHTML via
a small formRenderHTML() accessor (var declared in
tablehandler.go, same package). The embedded form.html file
is removed.
- build script: cp form/dist/form.html → internal/handler/form.html
step is gone (file no longer exists in the source tree). cp
tables/dist/tables.html → internal/handler/tables.html now
runs unconditionally rather than only on beta/stable cuts —
the renderer is a fixed binary component and dev iteration
needs the embedded copy refreshed every build. Channel-cascaded
apps (internal/apps/embedded/) stay channel-gated as before.
- form/dist/form.html still builds for standalone offline-only
use (downloadable from /releases/), but no longer goes into
the binary.
Tests:
- internal/handler/tablehandler_test.go and formhandler_test.go
rewritten for the in-dir layout. New test
TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
empty-form, create POST, re-edit row, and the negative cases
(Working/, non-mdl name) where the fallback must NOT fire.
- internal/handler/directory_test.go updated for the new
/<dir>/table.html redirect target.
- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
expectation updated.
- tests/form-safety.spec.js loads tables/dist/tables.html
(named form.html in the temp dir to trigger form-mode in the
dispatcher) so it tests the same bytes the server returns.
Title-element selector switches to #table-title.
- tests/tables.spec.js updates the status-filter test for the
uniform text-input filter.
Docs:
- AGENTS.md form-data system rewrites the URL conventions and
storage layout for in-dir; gains a Tables system section
parallel to forms describing the self-contained-directory
property; subfolder rules ("one table per folder by
construction; subfolders allowed and silently ignored as rows
— legitimate uses: nested sub-tables, per-row attachments,
drafts, future history sidecars") so we don't re-derive this.
Not included (deferred):
- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.
Schema (zddc/internal/zddc/file.go)
- New `Tables map[string]string` on ZddcFile. Map key becomes the URL
stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
relative to the .zddc pointing at a `*.table.yaml` spec describing
columns + the rows directory. No upward cascade in v1 — each
directory hosting a table declares it directly.
Server handler (zddc/internal/handler/tablehandler.go)
- `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
against the cascade's `tables:` declarations. Dispatch routes
table requests before the form-system intercept.
- `ServeTable` ACL-gates with `policy.ActionRead` and serves the
embedded `tables.html` template; client walks the directory itself
via the listing JSON or FS Access API.
- tables.html embedded via //go:embed — same pattern as form.html.
Frontend (tables/)
- Vanilla JS: app/context/util/filters/sort/render/main modules.
- Reads spec + row YAML files via window.zddc.source (HTTP polyfill
or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
client-side parsing.
- Sample fixtures under tables/sample/ for local testing.
Build + CI
- Lockstep build registers tables alongside the other 7 tools (HTML
output, embed mirror, versions.txt, release-output, tags).
- Playwright project added; `npx playwright test --project=tables`
is part of `npm test`.
Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.
Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>