Commit graph

509 commits

Author SHA1 Message Date
351b6555b7 fix(shared): .header-left/.header-right belong in the shared chrome rule
Five tools (browse, transmittal, landing, form, tables) were rendering
their headers as vertically-stacked blocks — the .app-header flex
container correctly laid out its left/right groups, but those groups
themselves had no display:flex rule, so their children (logo, title,
build label, action button) defaulted to block-level stacking.

Three tools (archive, mdedit, classifier) hid the bug because they
each carried their own copy of the .header-left/.header-right flex
rule in tool-local CSS. Same intent, slightly different gap values:

  archive:    left gap 0.75rem, right gap 0.5rem
  mdedit:     both 0.75rem
  classifier: both 0.5rem

Promote the rule to shared/base.css alongside .app-header (where the
class is used in every template anyway): left 0.75rem, right 0.5rem
(matching archive — the layout the user pointed at as the reference).
Delete the three local duplicates.

Now all eight tools use the same header chrome contract: logo + title
group + primary action laid out horizontally with consistent gaps,
icon buttons grouped tighter on the right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:09:23 -05:00
4cd39998aa chore(embedded): cut v0.0.17-beta 2026-05-10 07:57:40 -05:00
315d039880 refactor(landing): project landing is now a single-file SPA, not server-rendered
The /<project> landing page was server-rendered via
internal/handler/projecthandler.go's html/template — an inconsistency
against the project's "every tool is a single-file HTML" convention.
Convert it to a mode of the existing landing/ tool: same bundle now
serves both / (project picker) and /<project> (project workspace).

Mechanics:

  - landing/template.html: pickerView (existing markup) + projectView
    (new: stage cards, browse-all, MDL section, party-list slot).
    Mode toggles by adding/removing .hidden on the two containers.
  - landing/js/landing.js: detectMode() reads location.pathname;
    renderProjectMode() populates stage hrefs from the project segment
    and fetches /<project>/archive/?json=1 for the party list. init()
    forks based on mode; picker init was extracted to initPicker().
    Existing public API + behaviour unchanged for picker mode.
  - landing/css/landing.css: appended ~115 lines for the project view
    (.stages grid, .stage-card hover, .party-list, MDL formatting).
  - cmd/zddc-server/main.go: dispatcher's IsProjectRootURL fork now
    calls appsSrv.Serve(w, r, "landing", chain, absPath) rather than
    the deleted ServeProjectLanding handler.
  - internal/handler/projecthandler.go: trimmed to just the
    IsProjectRootURL predicate (the dispatcher still needs it for
    routing). Template + render code (~220 lines) deleted.

Net effect: same UI as before — same logo wrapping (now via
shared/logo.js, no longer a hand-rolled inline anchor), same stage
cards, same MDL instructions with party links — but the page is now a
single-file SPA that themes like the rest, follows the same logo and
stage-strip conventions, and could in principle be downloaded and
served standalone.

Tests:
  - 3 new tests/landing.spec.js cases: detectMode exposure, project
    workspace renders at /<project> with correct stage hrefs + title,
    party listing populates from JSON fetch and filters dot-prefixed
    entries.
  - The dispatcher test for /Project no-slash still asserts 200 +
    no-redirect; the served body is now landing.html instead of the
    server-rendered template, but both pass the assertion.

LOC: roughly net-neutral. -220 in projecthandler.go, +115 in
landing.css, +130 in landing.js, +60 in template.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:57:30 -05:00
bc5fcf6c73 chore(embedded): cut v0.0.17-beta 2026-05-10 07:47:10 -05:00
3fa2762c28 fix(zddc-server): project landing logo links to deployment root
The project landing page at /<project> had its own hand-rolled
header with <svg class="logo"> — not the canonical app-header__logo
class, and not loading shared/logo.js. So the logo on that page was
purely decorative while every other tool's logo (in the same beta
build) was wrapped by shared/logo.js into a clickable link to
/<project>. Inconsistent and surprising — clicking the logo from
mdedit/archive/etc. takes you to project landing, but clicking the
logo on project landing did nothing.

Inline the wrap directly in the template (the page is server-
rendered, so it can't lean on shared/logo.js the way bundled tools
do):

  <a class="app-header__logo-link" href="/" title="ZDDC home">
    <svg class="app-header__logo" ...>...</svg>
  </a>

href="/" because "next up" from the project landing is the
deployment root (the project picker / landing tool).

Also rename .logo → .app-header__logo for visual consistency, and
add the matching hover/focus styles inline. The test asserts both
the wrapping anchor and the canonical class name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:46:59 -05:00
cf5d7c2ea6 chore(embedded): cut v0.0.17-beta 2026-05-10 07:34:38 -05:00
7fd96c7c78 feat(shared): clickable logo links every tool's header to project home
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>
2026-05-10 07:34:28 -05:00
837cf47924 chore(embedded): cut v0.0.17-beta 2026-05-10 07:26:31 -05:00
6145bb0c87 feat(zddc-server): synthetic project landing page at /<project>
GET /<project> (no trailing slash) used to 301 to /<project>/ which
served the browse listing. Now it serves a small server-rendered
landing page with:

  - Four lifecycle-stage cards (archive/working/staging/reviewing)
    linking to the no-slash form of each canonical folder, so each
    card opens its default tool (archive view, mdedit sandboxed to
    working/, transmittal at staging/, mdedit at reviewing/).
  - A "Browse all files" link to the slash form for the generic
    file tree.
  - A "Master Deliverables List" section with step-by-step
    instructions for editing any party's MDL plus direct links to
    the MDL of each party already present under archive/ (sorted,
    case-preserved). Falls back to a friendly "no parties yet"
    message when the archive is fresh.

Trailing-slash form (/<project>/) is unchanged — still 200 +
embedded browse.html. The slash-vs-no-slash convention now extends
all the way up the URL tree:

  /                       → landing tool (project picker)
  /<project>              → project landing (this commit)
  /<project>/             → browse
  /<project>/working      → mdedit
  /<project>/working/     → browse
  ... etc.

Implementation:
  - new internal/handler/projecthandler.go — IsProjectRootURL
    predicate + ServeProjectLanding rendering an inlined html/template.
    Page styles are inline; tokens mirror shared/base.css and
    auto-flip on prefers-color-scheme: dark.
  - dispatcher in cmd/zddc-server/main.go: at the IsDir branch's
    no-slash fork, intercept depth-1 single-segment URLs before
    the historical 301. Other depths still 301 unchanged.

Tests:
  - internal/handler/projecthandler_test.go (4 cases): predicate
    coverage; landing page renders project name + four stage cards;
    on-disk parties surface as MDL links with case preserved; fresh
    project falls back to the no-parties-yet copy.
  - cmd/zddc-server/main_test.go TestDispatchSlashRouting: the
    "project root no-slash → 301" case becomes "→ landing (200)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:26:21 -05:00
e51d9fe908 chore(embedded): cut v0.0.17-beta 2026-05-10 06:44:45 -05:00
2f93fc1854 feat(zddc-server): reviewing/ slash form serves browse, no-slash serves mdedit
Apply the same slash-vs-no-slash convention to reviewing/ that already
governs the other three canonical project folders:

  /<project>/reviewing       → mdedit (default tool, via DefaultAppAt)
  /<project>/reviewing/      → browse (HTML) — shows the aggregator's
                                virtual <tracking>/ entries as a tree
  /<project>/reviewing/?json → aggregator JSON (handler.ServeReviewing)

Browse fetches the JSON listing for the URL it was loaded from, so
loading browse.html at /<project>/reviewing/ triggers a JSON request
back through the dispatcher → ServeReviewing → aggregator output.
Browse then renders the virtual <tracking>/ entries as clickable
folders. Clicking a tracking folder navigates to the per-submittal
view; clicking received/ or staged/ exits the virtual subtree
into canonical archive/ or staging/ paths via the polyfill's
explicit-url support.

The HTML branch in the reviewing dispatcher block was previously
calling appsSrv.Serve(..., "mdedit", ...) for trailing-slash URLs;
now it falls through to the canonical-folder block which routes to
ServeDirectory's HTML default (embedded browse.html).

Test: TestDispatchEmptyCanonicalProjectFolders extended with the
slash/<stage> → browse subtests, mirroring the no-slash → default
app set. All four canonical folders now have symmetric coverage of
both shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 06:44:35 -05:00
94323ea356 chore(embedded): cut v0.0.17-beta 2026-05-09 22:34:24 -05:00
0959d57dc2 feat(zddc-server): per-user-home .zddc is fenced (inherit: false)
When a user first writes to <project>/working/<email>/, the auto-own
.zddc EnsureCanonicalAncestors seeds at that folder now sets
acl.inherit: false in addition to the rwcda grant. This makes each
user's working subtree private by default — ancestor cascade grants
(e.g. a permissive *: r at the project root) no longer let anyone
read everyone else's drafts.

Implements the user-stated sandbox model: "no automatic or default
permissions other than the user's default folder which is instantiated
on first save — users can edit the .zddc files in their subtree to
allow access to others." The owner can edit
<project>/working/<email>/.zddc to add collaborators (or set
inherit: true, or list specific email patterns).

Mechanics:
  - new WriteAutoOwnZddcFenced — same shape as WriteAutoOwnZddc plus
    acl.inherit: false. Existing WriteAutoOwnZddc unchanged.
  - autoOwnDepthMatch returns (autoOwn, fenced); idx 2 under working/
    triggers fenced=true. The other auto-own positions
    (depth 1: working/staging/, depth 3: archive/<party>/incoming/)
    stay unfenced — those are shared lanes where ancestor admin
    grants should still apply.
  - staging/ children stay unfenced because staging folders are
    date+tracking-named (shared lane), not per-user.

Tests:
  - TestEnsureCanonicalAncestors_LazyCreation now asserts the fenced
    .zddc exists at working/<email>/ with inherit: false.
  - TestEnsureCanonicalAncestors_StagingChildNotFenced new — staging
    children stay plain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:34:11 -05:00
2aa29d1ec4 fix(mdedit): root file tree at <project>/working/ even with no trailing slash
mdedit's loadServerDirectory derived its file-tree root by stripping
back to the last "/" in window.location.href. For a no-trailing-slash
canonical URL (<project>/working) that gave the wrong root: the parent
project directory rather than working/ itself. Visible symptom on
zddc-server's auto-served URLs after the recent dispatcher changes —
mdedit at /Project/working would show the project's archive, working,
staging, etc. all together instead of being sandboxed to working/.

Detect the directory-shaped URL by checking whether the last path
segment contains "." (file marker). No dot = directory; treat the
whole path as the root and append the trailing slash internally.

  /Project/working          → root /Project/working/  (was /Project/)
  /Project/working/x/y/     → root /Project/working/x/y/ (unchanged)
  /Project/x/mdedit.html    → root /Project/x/  (file URL; unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:34:11 -05:00
464c80c1e1 chore(embedded): cut v0.0.17-beta 2026-05-09 21:51:08 -05:00
53eb58b90b refactor(browse): remove auto-filter rows from header
The two filter rows (📄 file/ext + 📁 folder) didn't really earn their
header real estate. Browse is for navigating directory structure;
ad-hoc filters across a tree of mixed file types and depths weren't
the right affordance, and the visual weight competed with the column
headers and the breadcrumb. Removed entirely:

  - template.html: dropped both <tr class="filter-row"> rows in <thead>,
    the related "Filter rows" help section, and the empty-state copy
    that mentioned the 📄/📁 rows.
  - init.js: dropped state.filters (file/folder/ext slots).
  - events.js: dropped the .column-filter[data-filter] input wiring.
  - tree.js: dropped recomputeVisibility() and the n.visible plumbing
    in visibleIds() and updateCount(). Render is now a straight depth-
    first walk over expanded subtrees; the count is just total rows.
    setFilter is removed from the public API.
  - css/tree.css: dropped .filter-row*, .filter-row__icon, and the
    browse-local .column-filter rules (.column-filter is also defined
    in shared/base.css for tools that still use it; that stays).

No test changes — tests/browse.spec.js never exercised filters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:50:58 -05:00
13e929b029 chore(embedded): cut v0.0.17-beta 2026-05-09 21:39:04 -05:00
d1a5a14132 fix(reviewing): case-insensitive on-disk lookup for archive/+staging/+
party/{received,issued}

The synthetic test fixture and many real deployments use PascalCase
folder names (Archive/, PartyB/, Received/, Issued/, Staging/). The
aggregator was hard-coding lowercase joins, which on case-sensitive
filesystems (Linux ext4) meant os.ReadDir returned NotExist and the
listing was empty even when the data was present.

Use zddc.ResolveCanonical to find the on-disk casing for each
canonical segment (archive/, staging/, then per-party received/ and
issued/), and emit URLs with the resolved casing so the dispatcher's
URL canonicalisation is a no-op pass-through.

The case-insensitive lookup was already used elsewhere (file API's
mkdir, tree.go's virtualUserHomeEntry); reviewing/ now matches that
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:38:50 -05:00
002e034119 chore(embedded): cut v0.0.17-beta 2026-05-09 21:37:22 -05:00
45005d164e feat(zddc-server): reviewing/ virtual aggregator + mdedit at the URL
Implements the reviewing/ aggregator described in the saved
project memory (~/.claude/projects/-home-user-src-zddc/memory/
project_reviewing_folder_design.md). reviewing/ stays in
VirtualOnlyCanonicalNames — never materialised on disk — and is
served as a join over archive/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.

Two depths, both trailing-slash:

  GET <project>/reviewing/?json=1
    → array of virtual <tracking>/ entries, one per submittal in
      archive/<party>/received/ that doesn't yet have a matching
      archive/<party>/issued/ entry. Sorted by tracking. URLs stay
      under reviewing/ so the user can drill into the per-submittal
      view. ACL: per-party, filtered like fs.ListDirectory.

  GET <project>/reviewing/<tracking>/?json=1
    → array of two virtual entries, received/ + staged/, with
      canonical URLs pointing back to archive/<party>/received/...
      and staging/... respectively. staged/ is omitted when no
      response draft exists yet.

When the response moves staging/ → archive/<party>/issued/, the
entry vanishes from depth-0 on the next listing. No mutation of
the reviewing/ subtree itself; pure join, recomputed on read.

Front-end at <project>/reviewing[/<tracking>/] is mdedit (per
user request). DefaultAppAt + AppAvailableAt extended to recognise
"reviewing" as a canonical mdedit-bearing folder. The polyfill in
shared/zddc-source.js is updated to follow listing entries' explicit
url field when present (absolute or root-relative) — that's how
mdedit's tree follows the depth-1 received/ + staged/ links into
the canonical archive/staging subtrees.

Dispatcher routing in zddc-server/main.go:
  - GET <project>/reviewing/[<tracking>/] with Accept: json
    → ServeReviewing
  - GET <project>/reviewing/[<tracking>/] with Accept: html
    → mdedit (rooted at the virtual path; polyfill fetches the
      JSON listing on its own)
  - GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
  - GET <project>/reviewing/<tracking> (no slash) → 301 to slash form

Tests:
  - handler/reviewinghandler_test.go (6 cases): IsReviewingPath
    classification + ServeReviewing depth-0/depth-1 with and without
    staged drafts + 404 on unknown tracking + empty when archive/ is
    absent.
  - apps/availability_test.go updated: reviewing/ now expects mdedit
    rather than "" (no default).
  - cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders
    extended to assert reviewing → mdedit at the no-slash form;
    older "no-slash/reviewing → 301" test removed.

Future work (not in this commit): write translation. Editing a file
under reviewing/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.md before
fetching — the user's URL bar moves to the canonical path on click.
A virtual-filesystem mode where the URL bar stays under reviewing/
throughout would require server-side write rewriting (translate
PUT/DELETE on reviewing/.../staged/... into the canonical staging/
path). Not needed for the MVP — links in mdedit's tree work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:37:08 -05:00
d08dcce211 chore(embedded): cut v0.0.17-beta 2026-05-09 21:13:24 -05:00
b3cea9b7a8 feat(zddc-server): no-slash canonical folders → default tool, even
when missing on disk

Mirror of the existing IsDir-branch behavior at line 873
(<project>/working → mdedit, <project>/staging → transmittal,
<project>/archive → archive) for the case where the folder doesn't
exist on disk yet. Without this, GET <project>/working on a fresh
project 404s instead of opening mdedit rooted at the (virtual)
working directory.

Behavior matrix for canonical project-root folders that don't yet
exist on disk:

  GET <project>/archive    → archive tool (project-root mode)
  GET <project>/archive/   → empty browse listing
  GET <project>/working    → mdedit rooted at working/
  GET <project>/working/   → empty browse listing (with synthetic
                             <viewer-email>/ home entry)
  GET <project>/staging    → transmittal rooted at staging/
  GET <project>/staging/   → empty browse listing
  GET <project>/reviewing  → 301 to /reviewing/ (no default app)
  GET <project>/reviewing/ → empty browse listing
  GET <project>/random     → 404 (still — non-canonical)
  GET <project>/random/    → 404 (still — non-canonical)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:13:10 -05:00
03babd34d2 chore(embedded): cut v0.0.17-beta 2026-05-09 20:59:43 -05:00
41e6576111 fix(zddc-server): canonical-folder fallback also at the dispatcher
The previous fix in fs.ListDirectory was insufficient — main.go's
dispatcher calls os.Stat(absPath) before reaching ServeDirectory,
and 404s on the missing path before the listing code ever runs.
Symptom: GET <project>/working/ on a fresh project still returned
"Not Found" despite the read-side fallback being committed.

Add the same fallback at the dispatcher level: when os.Stat returns
NotExist AND the URL ends with "/" AND the path matches
IsProjectRootFolder, fall through to ServeDirectory rather than
404. ServeDirectory's ACL check + ListDirectory's empty-listing
behavior take it from there.

Separately, fs.ListDirectory now initializes its result slice to
make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`,
so the JSON encoder emits "[]" rather than "null" for empty
listings — clients (browse, archive) expect an array and choke on
null.

New test TestDispatchEmptyCanonicalProjectFolders covers the four
canonical names (archive/working/staging/reviewing) on a project
where none of them exist on disk yet, plus the negative case (a
non-canonical missing path still 404s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:59:30 -05:00
1d12cfe804 chore(embedded): cut v0.0.17-beta 2026-05-09 20:35:10 -05:00
76e8dab009 fix(browse): treat 404 on directory fetch as empty, not an error
Expanding a folder that returns 404 used to throw "HTTP 404 fetching
…" through statusError, surfacing it as a red error toast. From the
user's POV, a missing or empty directory shouldn't be presented as a
load failure — empty IS the legitimate state.

fetchServerChildren now returns [] on 404; other non-2xx still throw.
Other failure modes (transport error, 500, malformed JSON) continue
to surface as before.

Server-side, zddc-server already returns 200 + [] for canonical
project folders that don't exist on disk (the prior commit). This
client fix covers the residual cases:
  - non-canonical paths that don't exist (deleted between listing
    and expand; race with concurrent writers)
  - non-zddc-server backends (Caddy file_server, plain nginx) where
    we can't change the 404 behavior

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:34:53 -05:00
3fc371752a feat(zddc-server): empty listing for canonical project folders
Listing <project>/{archive,working,staging,reviewing}/ when the folder
doesn't exist on disk now returns an empty 200 listing instead of 404.
The stage-strip nav links into these folders unconditionally; without
this fallback, clicking "Working" against a fresh project (where
working/ hasn't been written to yet) lands on a 404 page rather than
a usable empty view.

Mechanism stays consistent with the existing lazy-folder design:
  - GET on missing canonical folder → 200 + empty listing (this commit)
  - first WRITE under the same path → EnsureCanonicalAncestors
    materialises the on-disk folder + auto-own .zddc

reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the
fallback just makes its empty listing always renderable. The future
reviewing/ aggregator (recorded in project memory) will replace the
empty listing with the join-computed virtual entries.

The fallback is gated on IsProjectRootFolder — only depth-2 paths
matching one of the four canonical names. Non-canonical missing paths
still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound).

For working/ specifically the synthetic <viewer-email>/ home entry
still fires from virtualUserHomeEntry, so the user sees their own
placeholder even when working/ doesn't exist yet — first write into
that placeholder triggers the lazy-create chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:34:53 -05:00
702ccf3be0 chore(embedded): cut v0.0.17-beta 2026-05-09 20:27:35 -05:00
e9a7749153 style(nav): mount stage strip above the header instead of below
Project-level chrome (where in the project) belongs OUTSIDE
tool-level chrome (which tool / theme / help) in reading order.
Stage strip now sits above .app-header rather than after it.

One-line behavioral change in shared/nav.js (insertBefore(strip,
header) instead of insertBefore(strip, header.nextSibling)) plus
the matching nav.spec.js assertion. Visual hierarchy is now:

  ┌─────────────────────────────────────────┐
  │ projA / Archive · Working · …  (strip)  │  ← outer scope
  ├─────────────────────────────────────────┤
  │ [logo] ZDDC Archive  v…   [theme][help] │  ← tool chrome
  ├─────────────────────────────────────────┤
  │  page content                           │
  └─────────────────────────────────────────┘

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:27:23 -05:00
585e84f2f4 chore(embedded): cut v0.0.17-beta
Beta cut of the eight HTML tools into zddc/internal/apps/embedded/*
and the unified form/tables bundle into zddc/internal/handler/tables.html.
Each tool's on-page label changes from alpha → beta-stamped bytes;
no source changes beyond the build label itself.

The dev image (Dockerfile, devshell, ZDDC_REF=main) and the bitnest
test container both pick this up automatically — bitnest's path-unit
fired on the rebuild of zddc/dist/zddc-server-linux-amd64 and
restarted the container with the new embedded apps:

  embedded_apps=archive=v0.0.17-beta browse=v0.0.17-beta
                classifier=v0.0.17-beta form=v0.0.17-beta
                landing=v0.0.17-beta mdedit=v0.0.17-beta
                tables=v0.0.17-beta transmittal=v0.0.17-beta

Source-side commits since the previous beta:
  feat(landing): single-project click → <project>/archive.html
  feat(shared): non-blocking toast helper
  feat(shared): lateral project-stage strip
  feat(form): standalone empty-state welcome
  fix(tables): keepalive on beforeunload save path
  refactor(mdedit): drop window.* TOC globals
  refactor(archive): remove dead debounce
  style(transmittal): tokenize utility classes, drop !important block
  style: replace inline styles with CSS
  test(shared): zddc-source.js + toast + nav specs
  test(browse): smoke spec
  docs: tool counts + state pattern + polyfill gaps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:13:21 -05:00
b10468d4e3 fix(build): drop dead form.html reference in embedded-commit step
The build's embedded-commit step (beta + stable cuts) tried to
'git add zddc/internal/handler/form.html' alongside tables.html,
but that path doesn't exist — form.html was never an //go:embed
target. Form-mode renders through the unified tables.html bundle
via the zddcMode dispatcher; there's no standalone embedded form
HTML on the server side.

The line was a leftover from before form and tables were unified.
A beta cut against current main hits 'fatal: pathspec ... did not
match any files' and aborts before the embedded changes are
committed, leaving them dangling in the working tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:13:21 -05:00
7ced0395b6 feat(shared): lateral project-stage strip in every tool's header
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>
2026-05-09 19:50:30 -05:00
cc515b0f56 feat(landing): single-project click navigates to <project>/archive.html
Previously every project click — single or group — built
archive.html?projects=<list> and let the archive tool's URL-state
detection fan out from there. For a single project that's a
single-page-app trick that obscures the canonical URL.

Now single-project clicks navigate to <project>/archive.html instead.
The benefit is direct URL manipulation: the user can swap archive.html
for working/, staging/, reviewing/, archive/<party>/mdl/table.html etc.
in the address bar without going back through landing. zddc-server's
availability.go already auto-serves the right tool at each canonical
folder, so the destinations resolve without any server change.

Multi-project clicks (groups) keep the ?projects=A,B form because
there's no single subtree root. ACL-trimmed groups that collapse to
one project also take the new single-project path, since the result
is effectively a single-project view either way.

The ?v= channel selector continues to carry across both paths.

Two existing landing.spec.js assertions updated to match the new
single-project URL shape; multi-project assertion unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:46:14 -05:00
8ba029612e feat(shared): non-blocking toast helper available to every tool
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>
2026-05-09 19:04:41 -05:00
538167b5c8 style(transmittal): tokenize utility classes, drop !important dark overrides
transmittal/css/utilities.css hand-rolled a Tailwind-style utility
subset against hardcoded grayscale (#fff, #f9fafb, #d1d5db etc.)
tuned for light mode. Dark mode then needed a 17-line block of
!important rules in transmittal/css/base.css to swing each utility
class's background/text/border colors. That block was the worst
concentration of !important in the repo.

Tokenize the grayscale classes against shared CSS custom properties
(var(--bg), var(--bg-secondary), var(--text), var(--text-muted),
var(--border)) so the cascade picks up dark mode automatically:

  .bg-white, .bg-gray-50, .bg-gray-100  → var(--bg) / var(--bg-secondary)
  .text-gray-{400..700,900}             → var(--text-muted) / var(--text)
  .border, .border-{b,t}, .border-gray-* → var(--border)
  .hover:bg-gray-{50,100}               → var(--bg-hover)
  .focus:bg-white:focus                 → var(--bg)

Named-color text classes (.text-blue-600 / -green-600 / -red-600)
stay hardcoded — they encode link / success / danger semantics that
should not theme-shift.

The .table-filter-input dark-mode block also went — it was unused
(no element references it; .column-filter from shared is what gets
applied to the actual filter inputs).

!important count in transmittal's non-print CSS dropped from ~32 to
~12. The 6 transmittal Playwright specs still pass.

Verification needed: visually inspect transmittal in both light and
dark mode and flag any color regression. The token mapping is
mechanical but the live rendered output is the only proof.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:59:55 -05:00
0c48a583ad test(shared): smoke tests for the zddc-source.js FS-Access polyfill
The polyfill in shared/zddc-source.js is the seam between every tool
and zddc-server's HTTP API; if it drifts, every HTTP-mode tool breaks
together. Until now it had no direct test coverage — it was only
exercised through tool-level specs that load via file://.

Seven tests cover the documented surface:

- Public API shape on window.zddc.source matches the contract
- detectServerRoot() returns {handle:null, status:0} on file://
  (the negative path most tools branch on)
- httpListing() parses arrays, propagates 403 with err.status
- HttpDirectoryHandle.values() yields HttpFile/DirectoryHandles
  with correct kind+name from server-side is_dir/name fields
- HttpFileHandle.getFile() fetches, surfaces ETag, sets size
- moveFile() POSTs with X-ZDDC-Op: move, X-ZDDC-Destination, and
  optional If-Match — and returns the server's new ETag

Tests host inside classifier/dist/classifier.html (the smallest tool
that bundles zddc-source.js — browse, archive, landing, form do not),
mock fetch via page.route(), and add CORS + preflight responses so
file://-origin pages can read response headers like ETag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:49:11 -05:00
0024172be6 refactor(archive): remove unused debounce helper
archive/js/events.js defined a 10-line debounce function inside the
events IIFE that was never called anywhere in archive/. Dead code,
confirmed by grepping the whole archive/ tree for debounce(.

The plan was to extract debounce to shared/util.js so this file and
mdedit/js/utils.js could share one implementation, but mdedit's debounce
has only one caller (editor.js) so a shared abstraction would be
premature. Just delete the dead copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:48:57 -05:00
3baf160c88 style(shared,archive,browse,mdedit): replace inline styles with CSS
Six inline style="" attributes in archive, browse, mdedit templates
moved into stylesheets:

- The font-size:1.1rem override on #refreshHeaderBtn (three tools) is
  now a single rule in shared/base.css — the refresh ⟳ glyph genuinely
  reads smaller than ◐ / ?, so the rule lives next to the existing
  shared icon-button block.
- The flex-start justify-content + select-all margin on archive's
  Revisions column header become a .th-content--start variant +
  .select-all-checkbox class in archive/css/table.css.
- The 450px initial width + 200px min-width on mdedit's #file-nav move
  into mdedit/css/base.css; the runtime resizer continues to override
  via inline style.width when the user drags.

No visual change — the output is exactly equivalent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:48:44 -05:00
346cbba688 docs(architecture): state-mgmt patterns, zddcMode dispatcher, polyfill gaps
Three additive sections in ARCHITECTURE.md:

1. Promote a recommended state-management pattern. Three patterns coexist
   in the codebase (direct mutation, store pub-sub, Proxy-reactive); the
   recommendation for new tools is direct mutation + explicit re-render —
   it is the boring pick, debuggable, and what 5 of 7 IIFE-pattern tools
   already use. Reactive is appropriate when one state property drives
   ≥3 independent UI regions (transmittal's mode/published/locked).

2. Document the zddcMode dispatcher contract used by the unified
   tables.html bundle that hosts both the form renderer and the table
   view. Standalone form/dist/form.html intentionally has no zddcMode
   set; undefined means "form mode" by back-compat.

3. List zddc-source.js known gaps so callers don't fall into them:
   - recursive directory removal not implemented (HTTP backend has no
     recursive-DELETE endpoint; tools that rename non-empty dirs by
     copy+remove will leak the source dir)
   - no truncate semantics on writes (whole-file replacement only)
   - directory listings re-fetched per traversal (no client-side cache)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:48:33 -05:00
bf5ea7aa4f fix(tables): use fetch keepalive on the beforeunload save path
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>
2026-05-09 18:39:42 -05:00
c58511232f feat(form): friendly empty-state when standalone HTML opened directly
The form renderer is server-driven: zddc-server's form handler injects
schema/ui/data into <script id="form-context"> on every render.
Standalone form/dist/form.html opened from file:// has no context and
just rendered as a blank page below the header chrome — no orientation,
no submit button affordance was hidden, but nothing labelled what the
user was looking at.

Detect the empty-context case in form/js/main.js, render a small
.form-welcome card explaining that this tool is server-driven, and hide
the now-meaningless Submit button. The card uses shared tokens
(--bg, --border, --font-mono, etc.) so it themes correctly.

Note: the unified tables.html bundle that hosts the form via the
zddcMode dispatcher is unaffected — tables always sets zddcMode='form'
or 'table' before form's main.js boots, so the welcome only fires for
the standalone HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:30 -05:00
81b687a2eb test(browse): smoke test for empty state and mock-fs row render
Browse was the only one of the eight HTML tools with no Playwright
spec. Added two tests at the depth of mdedit.spec.js:

  - loads with the empty state visible and add-directory button enabled
  - renders rows from a mock directory after picking

Both use the existing tests/fixtures/mock-fs-api.js machinery (no new
test scaffolding) and run in ~2s under the existing chromium-only
project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:17 -05:00
0ad5b7dc0d refactor(mdedit): drop window.* TOC globals, call functions directly
mdedit's toc.js exported updateToc / clearActiveTocItem / setActiveTocItem
as window.* globals, which was the only remaining violation of the
two-globals discipline (only window.app and window.zddc are intended).
Other tools either use IIFE + window.app.modules.X, or — like mdedit —
declare top-level functions that are reachable across concatenated files
without going through window.

Removed the three window.* exports and unqualified the four call sites
in events.js, editor.js (×3), and file-system.js. setActiveTocItem was
already called bare elsewhere; the change is just dropping a
window.foo && check chain that's now unnecessary.

No behavior change — TOC generation and click-to-scroll work as before.
mdedit's three Playwright tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:39:10 -05:00
b7df50f458 docs: correct tool/artifact counts to eight tools / nine artifacts
The repo grew tables and browse since the docs were last revised, but
several paragraphs still said "six HTML tools" / "all seven" / "5 HTML
+ zddc-server". Updated AGENTS.md, ARCHITECTURE.md, CLAUDE.md, README.md,
and zddc/README.md to consistently reflect the current count
(8 HTML + zddc-server = 9 artifacts).

Also expanded README.md's tool table to include browse and landing,
corrected the tables description (no longer read-only), and modernized
the "Build & develop" snippet to show the canonical lockstep
./build alpha|beta|release path instead of the deprecated per-tool
--release form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:38:58 -05:00
3a4a1c7f39 feat(mdl): default columns mirror tracking-number components + customizable
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>
2026-05-09 11:09:31 -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
8e703dc61a feat(tables): editable cells phase 4 — copy/paste from Excel/Sheets
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>
2026-05-09 10:30:05 -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