Commit graph

512 commits

Author SHA1 Message Date
bb5e059477 feat(zddc): dir_tool key — make the slash/no-slash routing convention configurable
The trailing-slash directory form was hardcoded to serve `browse`. Add a
`dir_tool` .zddc key (cascades leaf→root, floors at `browse`) so an
operator can point a subtree's slash form at another directory-oriented
tool — the symmetric counterpart to `default_tool` (the no-slash
"specialized app"). handler.ServeDirectory now resolves it via
zddc.DirToolAt; JSON listing requests are unaffected (raw listing
always served, so browse can still enumerate).

Also collapse the no-slash dispatch: the on-disk-directory and the
virtual-declared-path branches in main.go each carried their own copy
of "default_tool → tables-carveout-or-apps.Serve → 302", with
inconsistent ACL checks. Extract one chokepoint, serveSpecializedNoSlash,
that enforces ACL uniformly for every default_tool route.

Updates ARCHITECTURE.md and AGENTS.md: the stale "Special folders" /
hardcoded-availability sections now describe the .zddc-cascade model
(defaults.zddc.yaml, the schema-key table, the slash/no-slash
convention, WORM, standard roles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:46:55 -05:00
c8d0afd1b8 chore(zddc): migrate mkdir auto-own hook to the cascade, drop dead predicates
The file API's mkdir post-hook still seeded auto-own .zddc files via the
hardcoded IsAutoOwnPath path-segment predicate, while
EnsureCanonicalAncestors had already moved to the cascade's auto_own:
flag. Point the hook at AutoOwnAt / AutoOwnFencedAt so both paths agree
and an operator's .zddc reshaping actually takes effect — fenced when
the new directory's own cascade level declares auto_own_fenced (per-user
working homes), unfenced otherwise.

Retires IsAutoOwnPath and WormMask (the latter already superseded by
WormZoneGrant's & VerbsRC) plus their tests, and the now-unused
path/filepath import in special.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:42:49 -05:00
9aa587aac0 feat(zddc): incoming/ is a controlled drop zone — project_team read-only, doc controller QCs
Clarify the incoming/ semantics per the workflow: it's the
counterparty's drop zone, not a free-for-all.

  - project_team gets read only here (inherited from the project
    level — they have no c/w, so they can see what's been dropped
    but not touch it). No change in effect; documented explicitly.
  - document_controller gets rwcd here (restated at the incoming/
    cascade level). The QC + transfer workflow — classifier renames
    files in place (w), then they move to received/ (delete here +
    worm-create there) — needs the delete bit, which the inherited
    project-level `rw` lacked.
  - The counterparty's uploader still gets access via a deployment
    .zddc (acl: { permissions: { "*@acme.com": cr } } at
    archive/Acme/incoming/.zddc) or by mkdir'ing a dated subfolder
    under incoming/ and owning it via the existing auto_own — both
    flows unchanged.

Test: standardroles_test now asserts the doc controller has rwcd at
incoming/ and a project_team member has only r there.

All Go + Playwright tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:29:44 -05:00
54dff4dcd3 feat(zddc): standard roles (document_controller, project_team) + role union/reset
Answers "can roles reset as well as add?" — yes, both now.

Role membership UNIONS across the cascade:
  - A deeper .zddc that defines an inherited role again with one
    extra member ADDS that member (was: deepest definition shadowed
    the ancestor's entirely).
  - New `reset: true` on a role definition breaks the union — that
    level's members are authoritative, ancestor definitions above
    are excluded; descendants below still union on top. Use it to
    give a project its own team independent of a deployment-wide
    default.
  - lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
    union members, stop at the first reset:true; finally fold in
    chain.Embedded.Roles as the baseline so a role declared only in
    defaults.zddc.yaml is "defined" (and a deployment's on-disk
    redefinition unions on top).

Admin checks are now role-aware:
  - IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
    MatchesPrincipal instead of MatchesPattern, so `admins:
    [document_controller]` resolves to the role's members. The
    strict-ancestor scan resolves roles only up to level i, so a
    role defined at the deepest level (= dirPath) never confers
    self-edit rights.

Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):

  document_controller — files into the WORM zones. Gets:
    - rw at the project level (read + overwrite-existing; NOT c, so
      it can't make arbitrary folders)
    - rwc at archive/ (can create party subfolders)
    - subtree-admin at working/ and staging/ (full create + manage,
      including taking over a fenced per-user home) — scoped HERE,
      not at the project root, so the WORM constraint still binds
      it in archive/<party>/received|issued
    - listed in worm: on received/ and issued/ → write-once-create
      survives the WORM mask

  project_team — read-only across the project. The per-user
    working home's fenced auto-own .zddc (rwcda for the creator)
    wins via deepest-match, so "read-only except what I own" falls
    out of the cascade with no special rule. Inside received/issued
    their r is preserved (worm: doesn't strip read).

archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.

Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:17:46 -05:00
2de2fdf92c refactor(zddc): worm: is a list of principals, not a {principal: verbs} map
Per design feedback: the verb string in a worm: entry was always
effectively "cr" (the key's whole job is to restore write-once-create
inside the locked zone, and you need read to see what you filed), so
spelling it out per-entry was redundant. worm: is now just a list of
principal patterns — email-globs, @role:name, or bare role names —
and every listed principal gets read + write-once-create. An empty
list ([]) still marks the WORM zone with no create-capable
principals.

Changes:
  - ZddcFile.Worm: map[string]string → []string
  - mergeOverlay: concat-dedupe (a deeper .zddc adds controllers);
    mergeStringSlicePreserveEmpty keeps `worm: []` non-nil through
    the overlay so it still marks the zone
  - WormZoneGrant: walks the list, grants VerbsRC to each matching
    principal; result is always ⊆ {r, c}
  - ValidateFile: validates each entry as an email-glob (role refs
    skipped — validated by the role machinery)
  - defaults.zddc.yaml: received/ and issued/ carry `worm: []`
  - tests updated to the list form (worm_test.go, fileapi_test.go)

All Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:40:15 -05:00
918f330a6f feat(zddc): WORM as a cascade key (worm:), retiring hardcoded path predicates
WORM (write-once-read-many) is no longer a special folder type keyed
off the literal names "received"/"issued". It's a cascade key —
`worm:` on any directory's .zddc — with the ACL-shaped semantics the
user described.

Schema:
  worm:
    "doc-control@example.com": cr   # email-glob or @role:name → verbs ⊆ {r, c}
  # an empty map ({}) is a WORM zone with no create-capable principals

Effect inside a WORM zone (any cascade level declares worm:), applied
AFTER the normal cascade ACL and BEFORE the admin escape hatch:
  - w / d / a stripped for everyone
  - c survives only via the worm: map
  - r survives via the normal ACL OR the worm: map (so a document
    controller who isn't in the project ACL still gets read+create)
  - worm: grants UNION across the cascade — deeper .zddc can name
    more controllers
  - admins (root / subtree) bypass entirely — handler does the
    IsAdmin check before the policy evaluator

defaults.zddc.yaml: archive/<party>/received and archive/<party>/issued
carry `worm: {}` (WORM zone, no controllers — the deployment names
its document controller by adding a deeper .zddc with
`worm: {<principal>: cr}`). The canonical convention is unchanged;
the difference is an operator can now mark any directory WORM, or
rename received/issued, without a code change.

Removed (hardcoded path predicates, superseded by the cascade walk):
  zddc.IsWormPath
  zddc.WormFolderLevelIndex
  zddc.splitPathSegments  (only IsWormPath used it)
Kept: zddc.WormMask (generic verb-set primitive), zddc.VerbsRC.

New:
  zddc.WormZoneGrant(chain, email, mode) → (verbs, inWormZone)
    Walks the chain for worm: declarations; unions the principal's
    grants masked to {r, c}.
  policy.InternalDecider.Allow: WORM block rewritten to consult
    WormZoneGrant instead of IsWormPath/WormFolderLevelIndex.
  ValidateFile: worm: keys validated as email-glob (or @role:name);
    values validated as verb strings ⊆ {r, c}.

Tests:
  - new worm_test.go covers the embedded convention, operator-granted
    controller, w/d masking, cross-cascade union.
  - special_test.go's TestIsWormPath / TestWormFolderLevelIndex
    retired; TestWormMaskStripsWDA kept.
  - fileapi_test.go's WORM tests updated: the doc-controller grant is
    now `worm: { _doc_controller: cr }` at issued/.zddc, not
    `acl.permissions: { _doc_controller: cr }`.
  - federal-parity and admin-bypass tests unchanged — the WORM mask
    still strips w/d/a and admins still bypass.

All Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:29:11 -05:00
9c7858c60a feat(zddc): Phase 4c — stage strip driven by cascade-declared children
The shared/nav.js stage strip previously hardcoded four stages
(archive/working/staging/reviewing) with their labels and target
URLs baked into the file. Operators couldn't add a fifth stage or
rename "Working" to "In-Progress" without forking shared code.

Now cascade-driven end-to-end:

  Server-side:
    listing.FileInfo gains a Declared bool field. fs.ListDirectory
    stamps Declared=true on every entry whose name matches the
    cascade's ChildrenDeclaredAt(parent) — both real on-disk dirs
    and virtual canonical injections. Bugfix in the same patch:
    virtualCanonicalFolders was passing the relative dirPath to
    ChildrenDeclaredAt (which expects absolute); now passes absDir.

  Client-side:
    shared/nav.js fetches the project root's JSON listing on
    DOMContentLoaded, filters to declared+is_dir entries, sorts by
    canonical workflow order (archive → working → staging →
    reviewing, then any extras alphabetically), and renders the
    strip. Labels read e.display_name → falls back to titleCase(name).

    Hardcoded FALLBACK_STAGES kicks in only on fetch failure
    (offline / file:// / non-zddc-server backend). Rendered
    immediately so the strip appears without flicker, then the
    cascade-fetched list replaces it once available.

  Effect:
    Project-3 (which has display: { archive: "Records",
    working: "In-Progress", ... } in its .zddc) now shows
    "Records · In-Progress · Outbox · Pending Responses" in every
    tool's strip. Project-1 still shows "Archive · Working ·
    Staging · Reviewing". No code change to render either; the
    cascade decides.

Tests:
  - tests/nav.spec.js relies on the mock server returning HTML at
    every URL, so the fetch fails over to fallback stages — the
    test renders the same Archive/Working/Staging/Reviewing labels
    it always did, with no test changes needed.
  - All 248 Playwright + all Go tests green.

Remaining client-side hardcode: archive/js/source.js +
archive/js/app.js's mode detection. Phase 4d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:34:56 -05:00
d90975662f feat(zddc): Phase 4b — grid mode driven by cascade default_tool
The /incoming/ path regex in browse/js/grid.js was the second-most
visible client-side hardcode of the canonical convention. Migrating
it to the cascade:

  Header surface:
    X-ZDDC-Default-Tool: <name>   The cascade-resolved default tool
                                  for the listing's directory. Empty
                                  header = no default declared.

  Client wiring:
    loader.fetchServerChildren reads the header into
    state.scopeDefaultTool on every listing fetch (initial mount,
    rescope on dblclick, popstate). grid.classifierAvailableHere
    now returns scopeDefaultTool === 'classifier' instead of
    regex-matching the URL.

  Effect:
    Grid mode auto-activates wherever the cascade picks classifier
    as the default — currently archive/<party>/incoming per
    defaults.zddc.yaml. An operator who sets default_tool: classifier
    on a custom directory gets grid mode there too, no code change.
    An operator who removes the default at incoming sees grid mode
    stop auto-activating there.

  Bootstrap timing fix:
    The initial events.init() runs applyResolvedViewMode before the
    detection fetch completes, so state.scopeDefaultTool is empty
    at that point and grid never auto-activates on first paint.
    app.js bootstrap now re-applies the resolved view mode after
    autoDetectServerMode returns, so a fresh /incoming URL lands
    on grid mode immediately.

The /incoming/ regex is gone. Two client hardcodes remaining
(archive source heuristics, shared/nav stage strip) — Phase 4c/d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:15:25 -05:00
4b04f61e4b feat(zddc): Phase 4a — drop_target cascade key, browse upload zone migrated
The last hardcoded client-side knowledge of the canonical convention
was the upload-zone regex in browse:

    var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;

Now declared in the cascade:

  Schema:
    drop_target: true|false   leaf-only; describes THIS dir
                              (not propagated to descendants)

  Lookup:
    zddc.DropTargetAt(root, dir) bool

  Surfaced to clients:
    Directory listings carry an X-ZDDC-Drop-Target: true response
    header when the cascade declares this leaf as an upload zone.
    No header = no drop target.

  Defaults populated:
    working / working/* / staging / archive/<party>/incoming
    all carry drop_target: true. Operators can extend (e.g. drop
    files on archive/<party>/received via override) or disable
    (e.g. drop_target: false at a specific staging subtree) without
    touching code.

  Browse migration:
    loader.fetchServerChildren reads the response header and stamps
    state.scopeDropTarget on every listing fetch. upload.js's
    currentScopeAllows now reads that flag instead of regex-
    matching the URL. Initial value is false in init.js so a
    listing failure (offline / server doesn't emit the header)
    safely defaults to "no drop zone".

Phase 4a closes the most visible asymmetry between server-side and
client-side cascade knowledge. The remaining client hardcodes
(browse grid-mode regex, archive source heuristics, shared/nav
stage strip) follow the same pattern when needed — Phase 4b/c/d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:12:41 -05:00
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
4af0d8ca7c feat(browse): drag-drop upload into working/staging/incoming
Drop files anywhere on a browse page; if the current scope is inside
a working/, staging/, or incoming/ subtree the files are PUT to the
current directory via the existing file API. Per-file ACL is enforced
server-side (authorizeAction); a 403 surfaces as a per-file error
toast and the rest of the batch proceeds.

UX:
  - dragenter → semi-transparent overlay with a dashed-border panel
    showing the destination path. Hides immediately on dragleave or
    drop.
  - drop → "Uploading N files…" toast, then per-file failure toasts
    inline, then a summary toast (success / partial / all-failed).
  - listing auto-refreshes after the batch so new files appear in
    the tree without a manual reload.

Scope:
  - upload-eligible paths are matched by /\/(working|staging|incoming)
    (\/|$)/i — same convention as the new grid-mode URL token.
  - 256 MiB per-file cap (UPLOAD_MAX_BYTES) since browse's single-
    body PUT loads the file as a Blob in the tab; larger uploads
    should use a dedicated client.
  - Outside the upload-eligible set the overlay never appears; drops
    are silently ignored (drag effect = none).

Sequential uploads keep progress predictable; parallel batching can
land later if needed. The module hooks document-level dragenter/leave
/over/drop so it works regardless of which pane the user drags over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:56:15 -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
ab44d75d03 fix(browse): URL-driven grid mode (?view=grid), default-on inside incoming/
User feedback: the Grid toggle button was on every page and showed an
explanatory empty state when classifier wasn't available — adding UI
to explain why UI didn't work. Cleaner approach: drop the button,
make the URL the source of truth, and default grid mode automatically
inside the only context where it's meaningful.

Behavior:
  - Inside any incoming/ path (case-insensitive segment match):
      → grid mode by default
  - Everywhere else:
      → browse mode
  - Explicit overrides via query string:
      ?view=grid    forces grid (only honored where classifier is
                    available; otherwise falls back to browse)
      ?view=browse  forces browse (always)

UI changes:
  - The Browse/Grid pill toggle is gone.
  - grid.js drops both empty-state messages; outside an incoming/
    path it just does nothing.
  - events.js owns resolveViewMode() / applyResolvedViewMode(),
    called on initial mount and after every client-side rescope
    (dblclick + popstate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:18:37 -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
d8459b87c2 fix(archive): scope to URL subpath when auto-served at a no-slash directory URL
When zddc-server auto-serves the archive tool at a sub-folder URL (e.g.
/Project-1/Archive/PartyA/Issued, no trailing slash, no archive.html
filename), the tool's autoConnectHttpSource was stripping the last URL
segment under the assumption it was a filename. Result: archive scanned
the PARENT directory (/PartyA/) instead of /Issued/, showing
transmittals from sibling folders the user hadn't asked for.

Fix: detect the URL shape before deciding what the scan root is.

  - Trailing slash → URL is already a directory; use as-is.
  - Last segment has a dot → filename (archive.html); strip to parent.
  - Last segment has no dot → bare directory name (auto-served URL);
    treat the entire path as the scan root and append '/'.

Now /Project-1/Archive/PartyA/Issued shows only the transmittal folders
inside Issued/ — the locally-mounted-folder experience the user expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:47:17 -05:00
dbb7f6c9b5 fix(browse): dblclick rescopes client-side instead of hard nav
Hard navigation to /<path>/ lets zddc-server's auto-serve kick in for
canonical folders — /archive/ swaps in the archive tool, /staging/ the
transmittal tool, etc. — so double-clicking a canonical folder from
inside browse silently swapped the user out of browse, contrary to
what they expected.

Fix: client-side rescope. navigateIntoFolder() now fetches the new
directory listing via the loader, calls tree.setRoot() with virtual
canonicals re-applied, and pushes the new URL via history.pushState.
The page never reloads. A subsequent reload still works (browse loads
itself at the new URL since trailing-slash → ServeDirectory → embedded
browse SPA).

Side effects:
  - augmentRoot (the canonical-folder injection helper) exposed via
    window.app.modules so events.js can re-apply it on rescope.
  - popstate handler: back/forward in the browser triggers the same
    rescope path with the historic URL.
  - Selection + preview reset on rescope; the previous file's preview
    isn't carried into the new scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:45:14 -05:00
cb2cf1ebe3 fix(browse): re-implement markdown editor layout on CSS Grid
The previous nested-flexbox layout produced indeterminate heights
inside the Toast UI editor host and made the TOC pane width fragile —
visually the editor and outline weren't laying out reliably. This
swaps the whole shell to CSS Grid, which gives every cell a definite
size.

Layout:
   ┌──────────────────────────────────────────────────────────────┐
   │  toolbar (Save | ● modified | status | source)               │
   ├─────────────────────────────────────┬────────────────────────┤
   │                                     │  Outline               │
   │   Toast UI Editor                   │  • Heading 1           │
   │   (md / wysiwyg / preview)          │    • Subheading        │
   │                                     ├────────────────────────┤
   │                                     │  Front matter          │
   │                                     │  title: …  rev: …      │
   └─────────────────────────────────────┴────────────────────────┘

Notes:
  - The shell mounts as a single child of #previewBody (not by
    re-classing previewBody itself), so the outer flex layout that
    fills the preview pane is preserved.
  - Sidebar is its own grid (outline 1fr + front-matter auto/max 40%),
    each section independently scrollable.
  - Resizer is a 6 px element on the grid column boundary; drag
    updates grid-template-columns. Keyboard left/right adjust by 24 px.
    Width persists across mounts (lastTocWidth) within a session.
  - parseHeadings now skips front-matter envelope + fenced code so a
    "##" inside ```bash``` doesn't show up as an outline entry.
  - scrollEditorToHeading uses findScrollParent + scrollTo({behavior:
    'smooth'}) so jumps feel less jarring.
  - Class names follow BEM: .md-shell__*, .md-side__*, .md-toc__*,
    .md-fm__*. Tests updated to the new selectors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:30:33 -05:00
d5638e9697 feat(landing): MDL card with party dropdown on project view
The Master Deliverables List section was a long prose block ("To edit
the MDL: 1. open the archive, 2. click into a party folder, 3. click
mdl…") followed by a bullet list of party links — visually
inconsistent with the four stage cards above it.

Replaced by a fifth card in the .stages grid styled like the others:
heading + short description + an inline select + Open button. The
select populates from the same fetchParties() helper that backed the
old <ul.party-list>; selecting a party + clicking Open navigates to
/<project>/archive/<party>/mdl/.

Empty/error states:
  - No parties yet: select shows "(no party folders yet)"; hint copy
    expands to explain the URL-based fallback (zddc-server still
    auto-renders archive/<party>/mdl/ even when the folder is missing).
  - Network error: select shows "(could not enumerate parties)"; user
    can navigate via the URL bar.

Updated landing.spec.js — the old "lists existing parties as direct
MDL links" test now asserts on #mdlPartySelect contents + click-to-nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:25:14 -05:00
319a3c0ce7 fix(browse): dblclick navigates; show virtual canonical folders at project root
#5 — Double-click on a folder no longer toggles collapse.

Root cause: the single-click handler called tree.render() immediately,
which replaced the clicked row element. The browser's double-click
detection requires the second click to land on the SAME target as the
first, so dblclick never fired for folders.

Fix: defer the single-click toggle by 220ms. A pending dblclick within
the window cancels the toggle and runs navigateIntoFolder instead.
Modifier-clicks (shift/alt for recursive) and ZIP expands skip the
deferral — they're never followed by a dblclick navigation.

#3 — Browse at /<project>/ now always shows the four canonical
folders (archive, working, staging, reviewing) even when they don't
yet exist on disk. Each missing folder is synthesized client-side as
a "virtual" row: muted icon + label + "(empty)" hint, double-clickable
to navigate. zddc-server already serves an empty listing for these
paths (commit 3fc3717), so navigation into a virtual folder works
without 404 and the user lands in a sensible empty workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:23:14 -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
7904a99c21 feat(browse): markdown front-matter pane + TOC resizer; misc UX fixes
Markdown preview pane now surfaces YAML front-matter above the TOC as a
key/value list (definition list), so engineering documents with header
metadata (title, revision, status, etc.) show their identity at a glance
without opening the file in mdedit. Front-matter parsing handles both
scalar and array values; arrays render as comma-joined.

TOC pane is now resizable (4px col-resize handle on its left edge);
preserves the user's chosen width across re-renders inside a single
session.

mdedit welcome banner moved inside #welcome-screen so the "browse opens
md in this same editor" callout only shows when no file is open — it
was previously visible in every state which was noisy.

archive.spec.js: wait for #filePreviewToggle to be attached before
clicking, fixing a Playwright flake where the preview button hadn't
mounted yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:30:26 -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
99c6eac0f2 fix(shared): bump dark-mode --primary to #5fa8e0 for WCAG AA contrast
The previous dark-mode --primary (#4a90c4) had ~3.3:1 contrast against
the white button text we pair it with — below WCAG AA 4.5:1 for
normal text. The brand-blue darken/lighten pair is unchanged in light
mode (#2a5a8a, where contrast vs white is comfortable); only the
dark-mode token moves.

  --primary:        #4a90c4 → #5fa8e0   (≈4.6:1 vs #fff)
  --primary-hover:  #5ba3d9 → #74b6e6
  --primary-active: #6ab5e8 → #88c4ec

Same change applied to both the `@media (prefers-color-scheme: dark)`
block (auto) and the `[data-theme="dark"]` block (manual override).

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
d6206b03e7 feat(shared): bake xlsx + utif + jszip + docx-preview into every tool
Removes every runtime CDN load. The "ship the record player with the
record" philosophy: a downloaded .html file works offline against any
file the user can open, with no network dependency at runtime.

Newly vendored under shared/vendor/:
  - xlsx.full.min.js (SheetJS, 928 KB) — XLSX/XLS preview
  - utif.min.js     (UTIF, 57 KB)      — TIFF preview

Already there but now used by mdedit too:
  - jszip.min.js, docx-preview.min.js

Call sites updated to drop the `await loadLibrary(URL)` pattern —
since the vendor JS is concatenated into the inline <script> at build
time, window.XLSX / window.JSZip / window.UTIF / window.docx are
available synchronously from page load.

Per-tool changes:

  - archive/build.sh:        +xlsx, +utif
  - classifier/build.sh:     +xlsx, +utif
  - transmittal/build.sh:    +xlsx, +utif
  - mdedit/build.sh:         +jszip, +docx-preview, +xlsx, +utif
                              (mdedit was the only tool not yet
                               bundling any of the preview deps)
  - browse/build.sh:         +utif
  - archive/js/table.js, classifier/js/preview.js,
    transmittal/js/files-preview.js, mdedit/js/file-tree.js (×2):
    drop the `await loadLibrary('…cdn…')` lines.
  - shared/preview-lib.js:
    drop the loadLibrary(UTIF) / loadLibrary(JSZip) wrappers; assume
    window.UTIF and window.JSZip are present.

Net bundle-size delta after baking:
  archive:     +990 KB → ~1.47 MB
  browse:       +57 KB → ~292 KB
  classifier:  +990 KB → ~1.43 MB
  mdedit:    +1100 KB → ~2.09 MB
  transmittal: +990 KB → ~1.63 MB

Docs (AGENTS.md, ARCHITECTURE.md) updated: removed the "runtime CDN
loading exception" paragraph and the table row that flagged xlsx as
CDN-loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:09:38 -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
dc72df83e3 fix(classifier): rename inline tree-empty placeholder out of .empty-state
classifier/js/tree.js was inserting a <div class="empty-state">No
folders found</div> inside the folder-tree pane when the tree was
empty. That conflicted with the shared .empty-state rule promoted
in the previous commit — which expects an outer flex container with
a child .empty-state__inner card, used for the top-level welcome
overlay.

The two usages aren't the same thing semantically (one is the
welcome screen; one is a tiny inline "list is empty" placeholder
inside the folder tree). Rename the inline one to .tree-empty to
remove the collision. The spreadsheet.css rule that targeted the
old class is renamed to match; same padding/text-align/color.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:22:50 -05:00
4acf348b21 chore(embedded): cut v0.0.17-beta 2026-05-10 14:21:18 -05:00
677ac01b32 refactor(shared): consolidate empty-state into shared chrome (BEM)
Three tools (archive, browse, classifier) independently implemented
an empty-state pattern with three different CSS class naming
conventions and slightly different rules:

  archive:    .empty-state + .empty-state-content (BEM-less)
  browse:     .empty-state + .empty-state__inner  (BEM)
  classifier: .empty-state + .empty-state-content (BEM-less)

Same visual intent ("nothing's loaded yet — here's a welcome card
with instructions"), implemented three times with subtly different
spacing, no shared body styling for h2/p/ul/li, and incompatible
class names that prevented a future tool from copy-pasting the
pattern.

Promote a single consolidated rule set to shared/base.css using
BEM naming throughout:

  .empty-state                       — base (flex centered, padding)
  .empty-state--overlay              — modifier: position absolute,
                                        top 50px to clear app-header,
                                        z-index 10. Used by archive
                                        and classifier (their empty
                                        states sit OVER the main
                                        layout).
  .empty-state__inner                — content card (left-aligned,
                                        text-muted, max-width 640)
  .empty-state__inner--centered      — modifier: tighter max-width
                                        500, centered text, 2rem
                                        padding. Used by tools whose
                                        welcome screen reads as a
                                        centered card.
  .empty-state__inner h2/p/ul/ol/li  — typography defaults
  .empty-state__inner .note          — italic small-print
  .welcome-list                      — bullet list with left-aligned
                                        text + auto margins; safe to
                                        nest inside a centered card.

Per-tool changes:

  - archive/template.html, archive/js/app.js: rename
    .empty-state-content → .empty-state__inner empty-state__inner--centered;
    add .empty-state--overlay to the outer .empty-state container.
    Also the runtime-injected unsupported-browser markup in
    showUnsupportedBrowserMessage() and the showHttpErrorState
    selector.
  - classifier/template.html: same renames.
  - archive/css/layout.css + components.css: delete .empty-state*
    and .welcome-list rules (now in shared).
  - classifier/css/layout.css: same. Keep .empty-state.drag-over
    locally — classifier is the only tool whose empty state acts
    as a drop target.
  - browse/css/base.css: delete .empty-state* (shared covers it).
    browse's template was already using .empty-state__inner so no
    template change needed.

LOC: shared/base.css gains ~70 lines; per-tool overrides lose ~85
combined. Net -15. More importantly, future tools can reuse the
pattern by adding two divs and (optionally) the --centered or
--overlay modifiers; no copy-paste required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:21:07 -05:00