Commit graph

5 commits

Author SHA1 Message Date
1721b4b1db feat(tables): explicit Save button + clearer dirty-row marker
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>
2026-05-19 08:38:35 -05:00
d3cd662740 feat(tables): editable cells phase 5 — undo + multi-cell ops
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>
2026-05-09 10:39:26 -05:00
cd751eb604 feat(tables): editable cells phase 3 — row-level save + ETag conflict UX
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>
2026-05-09 10:26:22 -05:00
e5bb7f216c feat(tables): editable cells phase 2 — schema-driven editor widgets
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>
2026-05-09 10:18:25 -05:00
08ce8a1266 feat(tables): editable cells phase 1 — selection + keyboard nav
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>
2026-05-09 09:16:39 -05:00