Commit graph

167 commits

Author SHA1 Message Date
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
e2c4700d32 refactor(zddc-server): demote routing-shape redirects from 301 to 302
301 Moved Permanently is cached by browsers effectively forever — when
we changed /<project> no-slash from "redirect to slash form" to
"serve project landing" earlier today, anyone who had visited the URL
under the prior behavior got stuck on the cached 301 indefinitely. No
server-side fix is possible after the fact; only a manual cache clear
in each user's browser releases the binding.

Demote every routing-shape redirect to 302 Found, which browsers do
not cache by default. Five sites:

  - handler/directory.go: no-trailing-slash → slash on directory URLs
  - main.go (4 sites):
      .archive/ canonicalization (deep /<project>/<sub>/.../.archive/
        path collapses to /<project>/.archive/)
      reviewing/<tracking> no-slash → slash
      reviewing/ default-app fallback to slash form
      generic IsDir + no-slash + no-default-tool fallback

301 → 302 trades "permanent semantics in the protocol" for "we can
change our mind later without trapping users on old behavior." For
these routes — all of which are convention-driven shapes the server
owns — the latter is what we want.

Test updates: five httptest assertions switch from
http.StatusMovedPermanently → http.StatusFound, plus five comment
strings ("301" → "302").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:37:02 -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
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
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
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