Commit graph

64 commits

Author SHA1 Message Date
6310afa922 chore(zddc): remove dead canonical-folder predicates
Phase 3 retired these symbols by migrating their consumers to the
.zddc cascade lookups. Removing them now that nothing references
them:

  - var  zddc.ProjectRootFolders
  - var  zddc.PartyFolders
  - var  zddc.AutoOwnCanonicalNames
  - var  zddc.VirtualOnlyCanonicalNames
  - func zddc.IsProjectRootFolder
  - func zddc.IsArchivePartyFolder
  - func zddc.IsArchivePartyMdlDir
  - func handler.isArchivePartyDir

The canonical convention is expressed in defaults.zddc.yaml and
consulted via lookups.go's DefaultToolAt / AutoOwnAt / VirtualAt /
IsDeclaredPath / ChildrenDeclaredAt / AvailableToolsAt /
IsToolAvailableAt. Operators override per-directory via on-disk
.zddc files; the embedded layer is the documented baseline.

Test removals:
  - TestCanonicalLists (lists no longer exist)
  - TestIsProjectRootFolder (function no longer exists)

Equivalent coverage lives in lookups_test.go's
TestDefaultToolAt_FromEmbeddedConvention,
TestIsDeclaredPath_FromEmbeddedConvention, etc. — which assert the
convention via the cascade's actual lookup path rather than the
predicates' return values.

handler.isAtArchivePartyMdlDir is RETAINED — it's still actively
consumed by RecognizeTableRequest's default-MDL fallback in
table.html URL resolution. That's a tighter file-path predicate
than the cascade walker would naturally express; can revisit if it
ever needs to become configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:01:43 -05:00
5e393cbeaf feat(zddc): Phase 3 completion — all canonical-folder behaviour now cascade-driven
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.

Schema added:
  available_tools: [tool1, tool2, ...]   concat-union across cascade;
                                          tools not in the union are
                                          denied auto-route at that path
  auto_own_fenced: true|false             generated auto-own .zddc
                                          carries inherit:false (private
                                          to creator)

Lookups added:
  AvailableToolsAt(root, dir)   union of available_tools across cascade
  IsToolAvailableAt(root, dir, tool)
  AutoOwnFencedAt(root, dir)    leaf-only

Cascade semantics finalised (per field):
  default_tool      → leaf→root walk (parent applies to descendants)
  available_tools   → leaf→root union (each level adds; baseline at root)
  auto_own          → leaf-only (creating THIS dir specifically)
  auto_own_fenced   → leaf-only (same)
  virtual           → leaf-only (THIS dir is virtual, not subtree)

Consumers migrated:
  apps.DefaultAppAt        → zddc.DefaultToolAt
  apps.AppAvailableAt      → zddc.IsToolAvailableAt (+ landing special)
  EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
  fs.ListDirectory empty-list fallback     → zddc.IsDeclaredPath
  fs.virtualCanonicalFolders               → zddc.ChildrenDeclaredAt
  dispatcher canonical-folder branches     → unified into one
                                              cascade-declared block

Hardcoded helpers REMOVED (dead code):
  apps.inAncestorWithName
  zddc.autoOwnDepthMatch / isAutoOwnDepthMatch

Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
  ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
  VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
  IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
  is used by special.go's IsProjectRootFolder. The rest are dead.

Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
  - no-slash, default_tool=tables  → ServeTable (default-MDL fallback)
  - no-slash, default_tool set     → apps.Serve(tool)
  - no-slash, no default_tool      → 302 to slash form
  - slash, any                     → ServeDirectory empty-list fallback

The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.

defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.

Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.

Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:36:33 -05:00
9d18047a46 feat(zddc): Phase 3 — DefaultToolAt cascade propagation + apps.DefaultAppAt migration
Two pieces:

1. Lookup helpers walk chain.Levels from leaf back to root. The
   "parent applies to descendants unless overridden" cascade rule
   means a working/ default_tool=mdedit propagates to deep paths
   like working/alice@example.com/notes/sub/deep without anyone
   declaring it at every level. AutoOwnAt and VirtualAt follow the
   same walk; explicit false at a descendant can override an
   ancestor's true (*bool semantics).

2. apps.DefaultAppAt delegates to zddc.DefaultToolAt. The hardcoded
   switch on parts[1] (archive→archive, staging→transmittal,
   working→mdedit, reviewing→mdedit, mdl→tables) and its case-
   sensitivity quirks now live in defaults.zddc.yaml. Operators can
   override any of these per-directory with an on-disk .zddc; no
   code change required.

Semantic improvement: archive/<party>/incoming previously defaulted
to "archive" (because parts[1]=archive and the switch didn't look
deeper). The new convention routes it to "classifier" — incoming/ is
the bulk-rename surface, not a record browser. Updated
availability_test.go to reflect.

All other DefaultAppAt cases — including case-fold (Archive/MDL),
mdl override, reviewing virtual, project root returning "", random
non-canonical names returning "" — produce bit-identical output.

Two new tests in lookups_test.go cover the propagation:
  - TestDefaultToolAt_PropagatesToDescendants
  - TestAutoOwnAt_DescendantCanDisable

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:05:36 -05:00
ea0d29ed17 feat(zddc): Phase 3a — populated defaults + cascade lookup helpers
Schema:
  - default_tool: string  (tool name served at this dir's no-slash URL)
  - auto_own: *bool        (mkdir post-hook auto-grants the creator)
  - virtual: *bool         (never materialise on disk; aggregator routes)

defaults.zddc.yaml: populated with the full canonical convention via
paths:. Top-level "*" matches any project; nested archive/working/
staging/reviewing declare the project-stage tools; archive's "*" /
mdl|incoming|received|issued tree declares the per-party surfaces.
All four party folders and all four project-root folders get their
default_tool; working / staging / archive/<party>/incoming get
auto_own; reviewing / archive/<party>/mdl get virtual. None of these
need on-disk dirs to exist.

Lookups (zddc/internal/zddc/lookups.go):
  DefaultToolAt(root, dir)     → cascade-resolved default tool name
  AutoOwnAt(root, dir)         → does mkdir auto-own here?
  VirtualAt(root, dir)         → never materialise on disk?
  IsDeclaredPath(root, dir)    → does the cascade say anything about this dir?
  ChildrenDeclaredAt(root, dir)→ literal child names declared by Paths

Each looks up via EffectivePolicy → leaf level → Embedded fallback,
so operators' on-disk overrides win and the embedded baseline carries
the convention.

Tests cover the embedded convention, operator overrides, and
inherit:false blocking the embedded layer. No consumer migration yet
— that's Phase 3b. Behaviour is bit-identical for current callers
since none of them consult the new lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:00:45 -05:00
2f08418fb0 feat(zddc): Phase 2 — paths: walker, recursive cascade
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.

Schema:
  paths:
    "*":              # literal-segment or "*" segment-wildcard key
      paths:          # recursive — each step matches one segment
        archive:
          paths:
            "*":
              paths:
                incoming:
                  title: "demo"

Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.

Semantics:
  - matchGlob: literal key first (case-insensitive on segment),
    "*" wildcard fallback.
  - mergeOverlay: top wins per-field on scalars; maps merge key-by-
    key with top overriding; lists concat-dedupe; Paths replaces
    (recursive walker threads it through naturally).
  - inherit:false at any on-disk level drops accumulated ancestor
    virtual sources AND zeroes chain.Embedded — the operator owns
    every rule from that level outward.
  - Behaviour is bit-identical when no .zddc declares paths:; the
    walker reduces to the prior linear cascade.

Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.

Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:55:12 -05:00
d84c1908f6 feat(zddc): Phase 1 — embedded defaults.zddc + inherit + show-defaults
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.

New pieces:

1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
   repo. Single source of truth for the baked-in baseline; intentionally
   minimal in Phase 1 (just title + empty acl) so existing deployments
   stay bit-identical until Phase 2 starts populating the schema.

2. //go:embed (defaults.go) bakes the bytes into the binary so
   shipped deployments don't need the file. Operators who want a
   starting point export with:

       zddc-server show-defaults > /var/lib/zddc/root/.zddc

3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
   layers in the embedded defaults as a baseline below the on-disk
   chain. Consumers that want the full effective view consult both;
   existing consumers that only read chain.Levels keep working
   bit-identically (the new field is additive).

4. New top-level `inherit:` key on ZddcFile. Default true. Set
   `inherit: false` on any on-disk .zddc to zero out chain.Embedded
   — the operator owns every rule from that level outward. Useful at
   the on-disk root to fully reject the embedded defaults; useful at
   deeper levels for sandbox subtrees.

5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
   dumps the embedded bytes to stdout — same shape as --print-rego.
   No flag plumbing needed beyond the existing args walk.

6. Tests: parse-roundtrip on the embedded file, presence in chain by
   default, inherit:false drops it, explicit inherit:true is a no-op
   versus the default.

Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:46:51 -05:00
5debd552ae feat: virtual fallback for archive/<party>/* folders + incoming fixture data
Three coupled fixes:

1. landing MDL card: Open button now navigates to /<project>/archive/
   <party>/mdl (no trailing slash) so the tables tool loads. The
   slash form would route to browse instead, which is not what users
   want when they click "Open MDL".

2. zddc-server canonical-folder fallback extended to
   archive/<party>/{mdl,incoming,received,issued}. New
   zddc.IsArchivePartyFolder() recognises any of the four party
   folders at depth 4. fs.ListDirectory returns [] for missing
   on-disk variants (mirroring the project-root behavior added in
   commit 3fc3717); the dispatcher routes slash forms to
   ServeDirectory and the no-slash mdl form to ServeTable, with
   non-mdl no-slash forms 302'ing to the slash form.

   So /Project-N/archive/<party>/incoming/ now lands on an empty
   browse listing rather than 404 when nobody has dropped files yet.

3. Fixture seeded with 3 files per party under incoming/ — naming
   intentionally NOT in transmittal-envelope form, so classifier
   (loaded automatically by browse's grid mode at /incoming/
   per the URL-driven view convention) has something to rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:36:03 -05:00
02bdf851c1 fix(shared/nav): stage strip uses no-slash targets so each stage opens its tool
The shared header strip pointed Working/Staging/Reviewing at the slash
form (working/, etc.), which now serves browse per the slash/no-slash
convention established earlier. The user expected those links to open
the stage's tool (mdedit for working, transmittal for staging, etc.) —
which is what the no-slash form serves.

Also drops the .html suffix from the archive target: <project>/archive
(no slash) → archive tool, same as the other stages. The currentStage
recognizer still accepts /archive.html as a fallback for any direct
URLs that survive in bookmarks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:11:00 -05:00
e85d5fc660 feat(zddc): canonical lowercase + .zddc display map + archive project titles
User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:

1. Test fixture migrated to lowercase canonical folder names.
   tests/data/test-archive.sh now creates archive/, received/, issued/
   on disk. Three projects also get human-friendly .zddc titles
   ("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
   a display: override demonstrating the new map. Party names
   (PartyA/B/C) stay unchanged — non-canonical.

2. New .zddc display: schema. Maps a child entry's on-disk name to a
   human-friendly label. The on-disk name stays canonical (lowercase
   for project-root folders); only the rendered label changes. Match
   is case-insensitive. Example:

     display:
       archive:   "Records"
       working:   "In-Progress"

   No upward cascade — a parent .zddc doesn't relabel grand-children;
   each directory sets display: on its own children.

3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
   the directory's .zddc display map and stamps DisplayName per entry.
   The field is omitempty so listings without overrides stay
   byte-identical to before.

4. Virtual canonical project-root folders (archive/working/staging/
   reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
   project root where the on-disk variant is absent in any case. This
   replaces the client-side injection in browse and lets the display:
   map apply to virtual entries the same way it applies to real ones.
   Browse drops its withVirtualCanonicals helper; the loader carries
   display_name through from the server's listing.

5. Archive app project picker dropdown shows the .zddc title of each
   project (sourced from ProjectInfo.Title in the server's project
   list), falling back to the folder name when no title is set. When
   they differ, the folder name is rendered in muted mono after the
   title for traceability. data-name still carries the canonical
   folder name so URL state stays stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:03:53 -05:00
ee67b9e596 fix(zddc-server): mdl slash form serves browse; .zddc viewable at every depth
Two related routing fixes:

1. /<project>/archive/<party>/mdl[/] now follows the slash/no-slash
   convention uniformly with the rest of the system:

     - mdl  (no slash) → tables app (default tool for mdl/)
     - mdl/ (slash)    → browse (ServeDirectory empty-listing fallback)

   Previously the slash form auto-redirected to mdl/table.html, which
   forced the user into the table view from any party-folder click and
   produced a confusing "Unrecognized table URL" error when the
   redirect race-conditioned. tableRowsRedirect now only redirects
   when a real on-disk table.yaml exists; the default-MDL virtual case
   stays in browse via the convention.

   New zddc.IsArchivePartyMdlDir helper recognises the canonical
   <project>/archive/<party>/mdl pattern at depth 4 (relative path).
   fs.ListDirectory uses it to return [] for the missing-on-disk case
   so browse renders the empty workspace cleanly. Test updated
   (TestServeDirectoryRedirectsDefaultMdl → TestServeDirectoryDefaultMdlNoRedirect).

2. <dir>/.zddc URLs now work at every directory depth.

   The dispatcher previously 404'd anything beginning with a dot
   (except /.archive and /<dir>/.zddc.html). New IsZddcFileRequest +
   ServeZddcFile handlers carve out the raw .zddc leaf so an operator
   can navigate to /Project-1/archive/PartyA/mdl/.zddc and inspect
   the rules effective at that depth.

   Semantics:
     - Method: GET / HEAD only. Writes go through the existing admin-
       gated form at <dir>/.zddc.html (unchanged).
     - ACL:    parent directory's read permission gates access; 404
       (not 403) is returned to non-readers so existence isn't leaked.
     - On disk: file bytes served verbatim with
       Content-Type: application/yaml and X-ZDDC-Source: file:<rel>.
     - Virtual: when no file exists at this level, a synthetic
       placeholder body is returned with a YAML-comment cascade
       summary so the reader sees exactly what rules apply here from
       ancestors. X-ZDDC-Source: virtual:zddc distinguishes it.

   The virtual body parses as valid YAML (`{}` after the comments) so
   downstream tooling that consumes the URL isn't confused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:45:16 -05:00
d052e9fed3 Round of UX fixes: tool strip removed, MDL routing, browse markdown layout, reviewing depth-2
Four user-reported items:

1. landing: remove the standalone-tool strip from the site picker.
   Per user, it was awkward — links pointing at zddc.varasys.io
   releases from inside a deployment is a layering confusion. The
   nav.tool-strip block in landing/template.html and its CSS are
   gone.

2. zddc-server: route /Project/archive/<party>/mdl[/] to the tables
   app for the virtual-MDL case where the on-disk folder doesn't
   exist yet. Previously fell through to 404 because the dispatcher
   only routed virtual mdl/ via the IsDir branch — the IsNotExist
   branch was missing the equivalent check. Now both shapes (with
   and without trailing slash) hit RecognizeTableRequest's default-
   MDL fallback and ServeTable serves the embedded tables.html.

3. browse: re-layout the markdown editor to mirror mdedit's layout.
   Was: sidebar on right with TOC top + front-matter bottom.
   Now: sidebar on LEFT with YAML front matter top + Outline bottom,
        content on RIGHT with an informational header (file title +
        save controls + status + source) above the Toast UI editor.
   New horizontal resizer between the front-matter and outline
   sections inside the sidebar (drag the row boundary; arrow keys
   step by 24 px). Browse test selectors updated.

4. zddc-server reviewing aggregator: extend to depth ≥ 2 so the
   user can preview files inside virtual reviewing/<tracking>/
   received/ and staged/ folders. IsReviewingPath now returns a
   sidePath ("received[/rest]" or "staged[/rest]"); ServeReviewing's
   depth-2 branch proxies the underlying real folder's listing,
   emitting folder entries with virtual reviewing/ URLs (so
   navigation stays in the aggregator) and file entries with
   canonical archive/ or staging/ URLs (so byte fetches resolve
   directly). ACL is enforced against the real path; depth-1
   received/ + staged/ URLs are now virtual too (was canonical),
   so the user smoothly descends into the depth-2 listing.

Tests updated for the new IsReviewingPath signature and the depth-1
URL shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:30:34 -05:00
b1479c5104 feat(zddc-server): include browse/form/tables in apps cascade
Wires up live alpha-dev iteration on bitnest. With this change a
`.zddc apps: <tool>: <path>` entry overrides the embedded copy for any
of the eight tools, not just five.

Two coupled fixes:

  1. zddc.AppNames had a five-entry list (archive/transmittal/
     classifier/mdedit/landing) — predating browse/form/tables.
     ResolveWithOverride's `if !IsKnownApp(app)` gate silently rejected
     those three before ever looking at the cascade, falling back to
     embedded with an "unknown app" error.

  2. handler.ServeDirectory hard-coded `apps.EmbeddedBytes("browse")`
     for the HTML directory-listing fallback, bypassing the apps
     subsystem entirely. Now takes an optional *apps.Server and
     delegates to appsSrv.Serve(w, r, "browse", chain, absDir) when
     wired, so the cascade is honored at bare directory URLs too
     (the most common way browse gets surfaced).

Both call sites in main.go and the test signatures in
directory_test.go updated. ValidateFile error message now lists all
eight known apps.

Verified end-to-end on bitnest with a root .zddc apps cascade
pointing at /srv/.zddc.d/source/<tool>/dist/<file>: every `./build`
on the host is now immediately visible after a hard refresh. Iteration
loop is `./build` (or `sh tool/build.sh`) then reload — no container
restart needed, since the apps subsystem reads the path source on
each request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:09:24 -05:00
c87fb7f4fa chore(embedded): cut v0.0.17-beta 2026-05-11 11:51:58 -05:00
436e8ca066 feat(landing): standalone-tool strip on the site picker
Above the Groups / Projects cards, a horizontal strip of one link per
tool — each pointing at the latest stable single-file build on the
canonical release host (zddc.varasys.io/releases/<tool>_stable.html).
Useful for "try this tool" / offline use without first picking a
project.

Seven links (Archive, Transmittal, Classifier, Markdown, Browse, Form,
Tables). Landing itself is omitted from the strip — clicking landing
from landing is a no-op. Each card has the tool name in the display
serif and a short sans hint underneath. Wraps on narrow widths instead
of scrolling horizontally; sits inside pickerView so it auto-hides on
the per-project landing view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:17:24 -05:00
6d72f5c770 feat(responsive): shared narrow-viewport baseline for the header chrome
Only transmittal had any @media (max-width) rules; the other seven
tools silently break below ~900px. Adds a baseline shared rule that
every tool inherits — desktop-first stays the same, but a tablet in
landscape or a window split next to a document remains usable.

@media (max-width: 800px):
  - tighter header padding + gaps
  - .app-header__title drops 18px → 16px
  - .build-timestamp inside .header-title-group hidden (it's
    traceability info, not a primary affordance — still reachable
    via help panel)
  - header text buttons get a smaller padding so they fit

@media (max-width: 480px) phone-width:
  - .app-header switches to column layout
  - .header-left and .header-right each span full width with
    justify-content: space-between

prefers-reduced-motion was already covered for the page-load stagger.

Each tool can still override in its own css/layout.css; this is the
shared floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:12:17 -05:00
1f03631d2d feat(motion): staggered page-load reveal on header chrome
The header is the first thing a user sees. A short staggered fade-in
(logo → title → action button → right-side icons over ~360ms) turns the
instant-pop-in feel into a subtle "the tool is composing itself" beat.

Pure CSS @keyframes (no JS), cubic-bezier(0.2, 0.7, 0.2, 1) for the
"settle in" easing curve. Respects prefers-reduced-motion. Total budget
~260ms before everything is visible — well under the threshold where it
becomes a perceptible delay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:10:45 -05:00
6260aa4860 feat(typography): bake IBM Plex Sans + Source Serif 4 into every tool
System-default font stack ('-apple-system, BlinkMacSystemFont, Segoe UI,
…') is the textbook generic admin-tool look. The tools have a real point
of view (engineering documents, traceability, immutability); the
typography should reflect that.

Picks:
  --font          → IBM Plex Sans (400 + 600). UI body text. Distinctive
                    engineering sans with tabular nums and proper figures.
  --font-display  → Source Serif 4 (600). Headings, page titles,
                    .app-header__title. Reads as "document" not "UI label."
  --font-mono     → unchanged. Platform mono fonts are already excellent
                    and engineering tools rarely benefit from a custom mono.

Wiring:
  - Raw .woff2 files live in shared/fonts/ (~60 KB total, latin subset,
    SIL OFL 1.1 — both families)
  - shared/fonts.css is base64-inlined data URIs for those three fonts
    (~80 KB after b64 overhead). Generated once from the snippet in
    shared/fonts/README.md.
  - Every tool's build.sh prepends shared/fonts.css before shared/base.css
    so @font-face is parsed before any rule references the family names.
  - Headings (h1-h6) and .app-header__title now use var(--font-display);
    .app-header__title bumped 17→18px and letter-spacing reset since the
    serif doesn't need the original sans-text tightening.
  - table/code/.tabular-nums get font-variant-numeric: tabular-nums so
    tracking-number columns align vertically.

"Ship the record player with the record": zero CDN dependency at render
time. Tools render identically offline and online. Per-tool dist sizes
grew by ~80 KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:59 -05:00
8be6c4d98b feat(shared): route window.alert() to non-blocking toast
There are 76 alert() call sites across the eight tools — three different
ad-hoc error-surfacing patterns (alert, console.error, classifier's own
showToast). Touching every site is a sweep with no judgment payoff:
every alert is "something went wrong, the user should know," which is
exactly what toast at level='error' is for.

Shim is one if-block at the bottom of shared/toast.js. It saves the
native window.alert as window.alertNative (so any truly modal-blocking
call site can opt back in by name), then replaces window.alert with a
function that forwards through window.zddc.toast(msg, 'error'). Effect
is global — every existing alert in every tool becomes a non-blocking,
ARIA-announced (aria-live=assertive) toast that the user can click to
dismiss.

handler/tables.html refreshed by ./build as a side effect (it bakes the
current tables/dist/ into the binary every build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:30:36 -05:00
f2af379ff5 chore(embedded): cut v0.0.17-beta 2026-05-10 19:22:14 -05:00
0b69367901 feat(browse): sort dropdown in the tree toolbar
The tree's underlying setSort API was carried forward from the old
table-with-clickable-headers UI but had no widget driving it after
the layout reshape. Adds an explicit dropdown in the toolbar:

  Sort: [Name (A→Z)         ▾]
        [Name (Z→A)            ]
        [Modified (new→old)    ]
        [Modified (old→new)    ]
        [Size (large→small)    ]
        [Size (small→large)    ]
        [Type (A→Z)            ]

Implementation:
- new tree.setSortExplicit(key, dir) — sets both axes in one call
  (the existing tree.setSort toggles direction on repeat-clicks,
  which is the right semantics for column-header clicks but wrong
  for an explicit dropdown).
- events.js parses the dropdown value as "<key>:<asc|desc>" and
  calls setSortExplicit. The dropdown is initialised to reflect
  the current sort state on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:22:03 -05:00
89d96b784f chore(embedded): cut v0.0.17-beta 2026-05-10 19:02:43 -05:00
0b382716e3 feat(browse): TOC pane + FS-API saves in the markdown plugin
Completes the markdown plugin's deferred v2 items:

1. TOC pane

A third pane to the right of the Toast UI editor lists every heading
in the current document, hierarchically indented by level. Click an
item → editor scrolls to that heading (markdown-mode uses
setSelection + preview scroll; WYSIWYG mode uses DOM text matching;
the target heading flashes briefly via primary-light background).
The TOC re-renders on every editor change (debounced 250ms) so it
stays in sync with edits.

Heading parser supports ATX-style `^#{1,6}\s+` lines, strips inline
markdown emphasis/code/links/strike from the displayed label.
Empty file → "Empty file." Headingless file → "No headings."

2. FS-API writes

Saves now route to whichever source the file came from:

  - node.handle + createWritable available → FileSystemWritableFileStream
    (local folder picker). The user's chosen file gets overwritten
    via the browser's File System Access API.
  - node.url + server source → PUT to the server URL (as before).
  - zip-virtual file → save disabled (no writable stream from JSZip).
  - Anything else → save disabled with a tooltip.

Save status surfaces via the existing toolbar (`Saved 10:42:18`) AND
a shared toast notification ("Saved readme.md" / "Save failed: …")
so the success/failure is visible regardless of whether the user is
looking at the toolbar.

Source-hint chip on the toolbar shows "local" / "server" /
"read-only (inside zip)" so the user knows which write path is
active before they make changes.

CSS additions in browse/css/tree.css for .md-toolbar, .md-split,
.md-editor-host, .md-toc-pane, .toc-list, and the .toc-level-1..6
indentation rules.

A new Playwright test exercises the markdown plugin end-to-end:
mounts the editor on a .md click, asserts the three DOM regions are
visible, verifies the TOC contains the three expected headings from
the test fixture's markdown content, and confirms the source hint
reads "local" for FS-API mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:02:32 -05:00
d77981407c chore(embedded): cut v0.0.17-beta 2026-05-10 15:47:02 -05:00
7d4d2dc9a2 feat(browse): two-pane shell + markdown plugin + grid mode (Phases A/B/C/D)
Reshape browse from "tree-as-table with popup preview" into a unified
file-experience tool with three layered behaviors:

  Phase A — Two-pane shell
  Phase B — Markdown plugin (Toast UI inline)
  Phase C — Grid mode (classifier workflow)
  Phase D — Deprecation banners on standalone classifier + mdedit

= Phase A: two-pane shell + lightweight preview plugins =

Browse's table view becomes a tree-pane on the left + preview-pane on
the right with a draggable resizer. Click a folder → expand inline.
Click a file → render in the right pane. The previous popup window
becomes an explicit "⤴ Pop out" button in the right-pane header for
users with a second monitor.

Preview rendering reuses shared/preview-lib.js (PDF iframe, image
<img>, TIFF, ZIP listing, text <pre>). Unknown types show a download
link. browse/js/preview.js refactored into renderInline (default) +
renderInPopup (Pop out button); both share the same plugin
dispatch logic.

Filter rows were already removed earlier this session. Sort columns
likewise — the tree is alphabetical by default; the underlying
setSort API still exists for future re-introduction.

= Phase B: markdown plugin =

New browse/js/preview-markdown.js: when a .md or .markdown file is
clicked, the right pane mounts a Toast UI editor (initial-value =
file contents) with a small toolbar containing Save + dirty indicator
+ status text. Save sends PUT through the file API for server-mode
files; non-server sources are read-only for now (deferred to a
follow-up that wires zddc-source.js writes too). Ctrl+S / Cmd+S
inside the editor saves.

Toast UI Editor (~700 KB JS + ~160 KB CSS) was previously bundled
only in mdedit/vendor/. Moved to shared/vendor/ so browse and mdedit
both pull from one location.

= Phase C: grid mode =

View-mode toggle [Browse | Grid] in the toolbar. Grid mode loads the
classifier tool as an iframe scoped to the current directory (server
mode at working/staging/incoming locations) — classifier's full
bulk-rename workflow without leaving browse. v1 implementation; a
future iteration could bundle classifier's modules directly into
browse for tighter integration. Hostile cases (file:// origin, paths
outside working/staging/incoming) show a friendly explanation
instead of a blank iframe.

new browse/js/grid.js handles the activation logic.

= Phase D: deprecation banners =

mdedit and classifier standalones gain a "this tool is being absorbed
into Browse" advisory banner. Both standalones remain fully
functional and continue to ship — they're useful for offline single-
file editing and air-gapped environments. The banner just points
users toward the unified browse experience.

= Files =

  + browse/js/preview-markdown.js   (markdown plugin)
  + browse/js/grid.js               (grid-mode plugin)
  M browse/template.html            (two-pane layout, view toggle, banners)
  M browse/css/tree.css             (two-pane CSS, replaces table styles)
  M browse/js/init.js               (state additions: selectedId, viewMode)
  M browse/js/tree.js               (rowHtml: <tr>+<td> → <div>)
  M browse/js/preview.js            (renderInline / renderInPopup split)
  M browse/js/events.js             (toggle wiring, resizer, click handlers
                                     adapted from <table> to <div>)
  M browse/build.sh                 (Toast UI vendor + new modules)
  R mdedit/vendor/toastui-*         → shared/vendor/  (one bundle, two tools)
  M mdedit/build.sh                 (paths)
  M mdedit/template.html            (deprecation banner)
  M classifier/template.html        (deprecation banner)
  M tests/browse.spec.js            (selectors updated for new layout +
                                     new "click file → preview" test)

Bundle sizes after this commit:
  browse:     ~1020 KB  (was ~290 KB; added Toast UI ~700 KB)
  classifier: ~1470 KB  (unchanged from prior baseline)
  mdedit:     ~2140 KB  (unchanged; vendor location moved but not added)

What's deferred:
  - TOC + front-matter pane in browse's markdown plugin (mdedit has
    these; browse v1 uses just the editor).
  - FS-API writes from browse's markdown plugin (server PUT works).
  - Classifier modules bundled directly into browse (v1 uses iframe).
  - Sort UI in the new tree (model still supports it; no widget yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:51 -05:00
875870501e chore(embedded): cut v0.0.17-beta 2026-05-10 15:09:49 -05:00
7ac2e1cc73 chore(embedded): cut v0.0.17-beta 2026-05-10 14:37:13 -05:00
9a98901683 chore(embedded): cut v0.0.17-beta 2026-05-10 14:23:00 -05:00
4acf348b21 chore(embedded): cut v0.0.17-beta 2026-05-10 14:21:18 -05:00
baf5958174 chore(embedded): cut v0.0.17-beta 2026-05-10 14:16:34 -05:00
b3e8c4127f chore(embedded): cut v0.0.17-beta 2026-05-10 14:09:33 -05:00
4cd39998aa chore(embedded): cut v0.0.17-beta 2026-05-10 07:57:40 -05:00
bc5fcf6c73 chore(embedded): cut v0.0.17-beta 2026-05-10 07:47:10 -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
e51d9fe908 chore(embedded): cut v0.0.17-beta 2026-05-10 06:44:45 -05:00
94323ea356 chore(embedded): cut v0.0.17-beta 2026-05-09 22:34:24 -05:00
464c80c1e1 chore(embedded): cut v0.0.17-beta 2026-05-09 21:51:08 -05:00
13e929b029 chore(embedded): cut v0.0.17-beta 2026-05-09 21:39:04 -05:00
002e034119 chore(embedded): cut v0.0.17-beta 2026-05-09 21:37:22 -05:00
d08dcce211 chore(embedded): cut v0.0.17-beta 2026-05-09 21:13:24 -05:00
03babd34d2 chore(embedded): cut v0.0.17-beta 2026-05-09 20:59:43 -05:00
1d12cfe804 chore(embedded): cut v0.0.17-beta 2026-05-09 20:35:10 -05:00
702ccf3be0 chore(embedded): cut v0.0.17-beta 2026-05-09 20:27:35 -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
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
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
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
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