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>
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>