Commit graph

31 commits

Author SHA1 Message Date
1bd73b1512 feat(browse): CodeMirror for all editable text files; drop the .zddc form; tidy access dialog
- Editor routing: the CodeMirror editor is now the general editor for editable
  text files that aren't markdown — yaml/.zddc keep schema lint + completion +
  hover; txt/csv/tsv/json/xml/html/css/js/log/ini/conf/… open as a plaintext
  code editor (line numbers, find, save) instead of a read-only <pre>. HTML now
  edits its source (was an iframe render); PDF stays an iframe. Mode is yaml-only
  (the vendored CM mode); others plaintext, lint gutter suppressed.
- Abandon the .zddc schema FORM (preview-zddc-form.js deleted): .zddc opens in
  CodeMirror. Guided dialogs (Manage access, …) are the front door for common
  tasks; CodeMirror is the full/raw surface. One fewer half-baked middle layer.
- Manage Access dialog laid out cleanly: grid rows (who fills + shrinks, level
  sizes to content) so long emails/levels never overflow; short level labels
  with tooltips + a one-line legend; box-sizing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:18:31 -05:00
74ffefa191 feat(browse): guided "Manage access" dialog — task-first, no raw YAML
First of the intent-driven actions that replace raw-YAML editing as the front
door. Right-click a folder (or the pane) → "Manage access…" → a dialog of
people + friendly levels; the raw .zddc editor is demoted to "Edit raw policy
(.zddc)…" as the advanced escape. Both gated by the same admin authority.

- browse/js/manage-access.js: reads the folder's on-disk .zddc, presents
  principals as View / Contribute / Edit / Manage (admins: membership), plus an
  "inherit from parent" toggle (uncheck = make private). Save maps levels back
  to verbs (r / rc / rwc / admins:), merges ONLY the access bits into the doc
  (every other key preserved), and PUTs. Unrecognised verb strings show as
  "Custom" and are preserved untouched.
- Menu: "Manage access…" (guided) is now primary; "Edit raw policy (.zddc)…"
  is the escape hatch.
- Self-contained modal + CSS; refreshes the listing on save.

Sets the pattern for the remaining operator tasks (role members, display label,
project convert vars, register a party).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:03:27 -05:00
d44e1b01bf feat(browse): unify the .zddc lint onto the JSON schema (baked, single source)
The .zddc lint used a hand-kept TOP_KEYS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS list
with bespoke type tags — a parallel grammar that drifted from the Go structs
(we'd already had to patch in party_source/history/etc.). Replace it with one
schema-driven validator over the same JSON Schema that now drives completion +
hover, so lint/completion/hover/form all share ONE grammar.

- Bake zddc.schema.json into the bundle at build time (window.__ZDDC_SCHEMA__),
  the exact file the server serves at /.api/zddc-schema. Synchronous + works
  offline (file://), where that endpoint is unreachable — drops the runtime
  fetch entirely.
- validateZddc() now walks the parsed doc against the schema: type, enum,
  pattern, properties, additionalProperties (false|schema), patternProperties,
  items, and the recursive $ref:"#" (paths:). Same {keyPath,severity,message}
  shape, so findLine + the CM lint helper are unchanged.
- Delete TOP_KEYS/ALLOWED_TOOLS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS + walkObject/
  checkValue/addTypeErr. preview-yaml completion now reads getZddcSchema too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:30:17 -05:00
a13ce12a75 feat(browse): shared schema completion + hover docs; bring it to the .zddc editor
Generalise the front-matter completion into a reusable, provider-based helper
(browse/js/yaml-complete.js) and wire BOTH YAML editors through it. Still fully
deterministic — every candidate and doc string comes from a schema, no AI.

- yaml-complete.js: shared CodeMirror plumbing (indent→key-path, sibling scan,
  show-hint, debounced hover tooltip) + two providers:
    · flatProvider  — a fixed field list (front matter), with an exclude set.
    · schemaProvider — a JSON Schema walker that resolves nested key-paths
      through properties / additionalProperties / patternProperties and the
      recursive $ref:"#" .zddc uses for paths:; keys from object properties,
      values from enum / boolean, hover docs from `description`.
- .zddc editor (preview-yaml.js): fetch /.api/zddc-schema once and attach the
  schemaProvider on .zddc files — nested-key completion at every level, enum
  values (default_tool, dir_tool, views.*.tool), booleans, and hover docs.
  Plain .yaml stays lint+highlight only.
- Front-matter editor (preview-markdown.js): refactored to delegate to the
  shared helper via flatProvider (excluding the filename-driven identity keys);
  the bespoke frontMatterHints is gone — one implementation now.
- Hover-doc tooltip styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:18:07 -05:00
2c877bd5b7 feat(browse): schema completion in the front-matter editor (keys + enum values)
Vendor CodeMirror's show-hint add-on and wire deterministic, schema-driven
completion into the markdown front-matter pane — NO heuristics, no AI; every
candidate comes from the converter's own field list.

- Vendor codemirror-show-hint.min.{js,css} (CM 5.65.x add-on); concat in
  browse/build.sh after the core CM bundle so it extends window.CodeMirror.
- Server: add a structured `values` enum to convert.FrontMatterField (doctype →
  report/letter/specification, numbering → true/false), exposed via the existing
  /.api/frontmatter JSON. Tracks the template set; keeps the server as the
  single source of truth instead of parsing the hint prose.
- Client: frontMatterHints() completes recognised KEYS at line start (excluding
  the filename-driven identity keys and keys already present) and enum VALUES
  after `key:`. Picking an enum key auto-opens its value list. Triggered on
  Ctrl-Space and automatically as you type (completeSingle:false — always a
  menu, never an auto-guess). Themed dropdown for dark mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:09:37 -05:00
ef849ab3fa feat(shared): replace floating elevation toggle with a header profile menu
Drop the bottom-right floating "Admin mode" switch in favour of a proper
account menu in the header's upper-right (every tool's .header-right).

New shared/profile-menu.{js,css}: a circular avatar button (email initial)
opening a dropdown with the signed-in email, an "Admin mode" item (only for
can_elevate principals — drives elevation.setOn/setOff, drops on leave),
Profile (/.profile), and Access tokens (/.tokens). The panel is portaled to
<body> + position:fixed so it overlays content reliably regardless of the
app's stacking contexts; the button shows a red ring while elevated.

No logout: authentication is the upstream proxy's concern (oauth2-proxy /
Authelia) — ZDDC owns no session, so the menu doesn't render sign-out.

elevation.js keeps the state machine (cookie, armed banner/frame, ephemeral
pagehide-clear, zddc:elevationchange, ?admin= URL) but no longer renders any
control — the profile menu is the UI. elevation.css drops the floating-
toggle styles (keeps banner + frame). All 7 templates drop the dead
elevation-toggle placeholder; all 7 build.sh bundle profile-menu.{js,css}.

Validated in a containerized browser: menu items, links, elevation arming +
armed ring, dropdown overlays content, no floating toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:43:43 -05:00
cd645c53bb feat(browse): schema-driven .zddc form view (option fields editable, structure read-only)
The primary editor for a .zddc is now a FORM, not raw YAML — so configuring a
project doesn't require understanding the cascade. preview-zddc-form.js fetches
the .zddc JSON Schema (/.api/zddc-schema) and renders:
  - OPTION fields editable — title, admins (email list), roles (per-role member
    lists, + add role). These are the "blanks an operator fills."
  - STRUCTURE + unrendered keys (paths, worm, tools, behaviors, field_codes,
    display, …) shown read-only in a collapsed "Structure & advanced" section
    (classified by the schema's x-zddc-tier).
  - An "Edit raw YAML" escape that hands off to the CodeMirror editor.

Save merges the edited option values back into the parsed document — preserving
every structure/unrendered key — and PUTs the YAML via util.saveFile, which
works for an on-disk .zddc AND a .zddc.zip bundle member (ServeZipWrite).
Edit authority is the existing gate (ActionAdmin 'a', or an editable bundle
member); non-admins get a read-only form.

Wired as the primary .zddc editor in preview.js (before the YAML plugin) and
into the unsaved-changes guard. Raw YAML remains the power-user fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:02:47 -05:00
e2179d167b feat(browse): capability/role/tier-driven, context-correct menu system
Reworks the browse menu/tree interaction into a declarative, contextually
honest model and moves view settings onto a toolbar — the menu is the UI to
the system, so it should be familiar, inviting, and only ever offer what
applies.

New declarative menu model (browse/js/menu-model.js):
- Every action is one descriptor with a TYPE predicate (appliesTo) and a
  CAPABILITY predicate (enabled)+tooltip. Row/pane menus are projections over
  it; separators are derived from group changes. Designed data-shaped so a
  future server-sourced manifest (zddc.zip) can supply/extend it.
- Hybrid visibility: type-inapplicable actions are OMITTED (New folder on a
  file, Expand on a file); permission/role/tier-gated actions are SHOWN
  DISABLED with a reason — so a lower tier sees what a higher role unlocks.
- Roles are NOT hardcoded: ordinary actions gate on the verbs the server
  returns (node.verbs / path_verbs), so any operator-defined role works. Only
  the two intrinsically-special tiers are recognised by name — site admin
  (is_super_admin) and project/subtree admin (path_is_admin), surfaced as the
  "Edit access rules…" item; both come from the existing /.profile/access.
- The headline fix: New folder / New markdown file no longer appear on file
  rows (they target a folder or the current dir).

events.js: deletes the ~350-line inline buildTreeRowMenu/buildPaneMenu/
SORT_BY_ITEMS; opens menus via menuModel projections through one openRowMenuFor
/openPaneMenu path shared by right-click, the hover kebab, and the keyboard
menu key (ContextMenu / Shift+F10). Injects action impls via menuModel.configure
to avoid a circular dep. Prefetches the scope /.profile/access (memoised) on
load/rescope/refresh/popstate so menus never fetch at open time.

Discoverability + a11y: a per-row ⋯ kebab (tree.js + new icon-ellipsis sprite,
revealed on hover/selection/focus) opens the same menu; keyboard menu key
supported.

Toolbar: Sort + Show-hidden moved OUT of per-row right-click menus into the
tree-pane toolbar, plus New folder / New file buttons (act on the current dir,
greyed with a reason when create access is lacking). Help copy updated.

Icons: dropped the 3 stray emoji from menu items (consistent, VS Code/Finder
style); only new sprite is the kebab's icon-ellipsis.

Tests: +5 browse specs (file row omits New-folder; folder row shows it; a
read-only server node greys Rename with a "write access" tooltip via a pure
menuModel unit; toolbar Sort/Show-hidden drive state + New buttons present;
kebab and Shift+F10 both open the menu). All 23 browse+conflict+diff green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:21:02 -05:00
8edbb81958 feat(browse): lost-update protection for editors + shared conflict dialog
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.

- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
  (or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
  the PUT response (so save→edit→save adopts the new version and doesn't
  false-conflict); throws ConflictError (.status===412) on a precondition
  failure so callers branch cleanly. New saveCopy() parks a conflicting edit
  as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
  from the content GET (the listing JSON carries no per-file etag); threaded
  into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
  (reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
  Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
  deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
  adopt the returned etag on success, and on 412 open the dialog (Overwrite
  re-fetches the current etag then re-saves — re-conflicts on a third writer
  rather than blind-forcing; Reload clears dirty first so the renderInline
  guard skips its confirm). FS-Access mode sends no precondition (no
  concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
  on 412, new-etag returned, force omits the precondition, dialog renders the
  diff and each action resolves via its callback. Drives the fresh dist build
  over file:// with a stubbed fetch (the test binary embeds the committed
  browse.html, not dist, so a server-mode E2E would run stale code).

All browse + diff + conflict specs pass (18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:24:15 -05:00
bbbf5326e7 refactor(browse): consolidate duplicated helpers into util.js; fix YAML save divergence
Nine copies of escapeHtml (some escaping single-quotes + handling null,
others not), two byte-identical hashContent hashers, two saveContent
writers, two isZipMemberNode predicates, the ISO-date + YAML-quote helpers
duplicated across the workflow modals, three /.profile/access email
fetchers, and three byte-size formatters had all drifted across the browse
modules. Hoist a single browse-local window.app.modules.util (no new global;
concatenated right after init.js) and alias the call sites to it.

Reliability fix folded in: the YAML editor's saveContent skipped the
upload.ensureWritable() escalation that the markdown editor performs, so
saving a .yaml/.zddc file to a read-only-picked local folder failed where
markdown succeeded. Both now go through util.saveFile, which always
escalates — the shared writer makes the two editors impossible to drift
apart again.

Canonical escapeHtml is the strict superset (escapes & < > " ', null →
"") so it's a safe drop-in for every prior variant. fmtSize gains the GB
tier everywhere (history.js previously capped at MB). Also removes the dead
stage.js fetchSelfEmail (defined, never called).

Net −200 lines across the modules. No behavior change beyond the save fix;
all 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:07:00 -05:00
3e8737b7c9 feat(browse): preview .docx/.xlsx + fix markdown-editor horizontal overflow
DOCX/XLSX preview: add renderDocx (docx-preview) and renderXlsx (SheetJS)
to shared/preview-lib.js — the natural home alongside renderTiff/
renderZipListing, reusable by every tool. browse dispatches Office files
to them in both the inline pane and the pop-out window via the existing
preview.isOffice() check, and browse/build.sh now bundles the
docx-preview + xlsx vendors. Renderers degrade to a friendly message if a
tool doesn't bundle the vendor.

Overflow fix: .md-shell used `grid-template-columns: 280px 1fr`. A bare
`1fr` is `minmax(auto, 1fr)`, whose `auto` floor is the editor's
min-content width (Toast UI's toolbar) — so the content track refused to
shrink and the whole shell overflowed #previewBody as the window narrowed
instead of the editor reflowing smaller. Switch both tracks to
minmax(0, …) in the CSS and in the three JS spots that rewrite the
columns on sidebar-drag, and give .md-shell__sidebar min-width: 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:55:14 -05:00
9972e6773a feat(browse): markdown version-history viewer with diff + restore
Adds a "History…" context-menu item on markdown files in a history:true
subtree (server mode only — the audit is server-stamped). It opens a modal
that lists every saved version newest-first (timestamp + author + size,
current flagged), lets you View any version, Diff any two, and Restore one
(a forward PUT — non-destructive).

- shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff),
  prefix/suffix trimming + a cell cap so large files don't stall the UI.
- browse/js/history.js: the modal (list / view / diff / restore), talking to
  GET <url>?history=1 and ?history=<sha>.
- loader.js carries the per-file history flag; events.js adds the menu item.
- Wired diff.js + history.js + history.css into browse/build.sh; diff.js into
  the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:49:00 -05:00
b5b3c92905 feat(shared): cap.js client helpers for permission gating
Three small helpers under window.zddc.cap, wired into every tool's
build:

  cap.at(path)               — Promise<AccessView|null>. Fetches
                               /.profile/access?path=<urlpath> and
                               memoises per-path for the session.
                               Used by tools to gate top-of-page
                               affordances on path_verbs / path_is_admin
                               / path_can_elevate_grant.
  cap.has(node, verb)        — boolean. Reads the listing entry's
                               verbs string for the named verb.
                               Falls back to node.writable for 'w'
                               when verbs is absent (offline FS-API
                               listings or pre-promotion clients).
  cap.handleForbidden(resp,  — parses a 403 response's JSON body for
                  opts)        missing_verb and renders an error
                               toast. When opts.path is supplied AND
                               the path-scoped access view reports
                               path_can_elevate_grant covering the
                               missing verb, the toast appends an
                               "Elevate" button that flips the
                               elevation cookie and reloads.

Browse loader.js + tree.js carry the new verbs field through to the
node objects so context-menu gating can call cap.has(node, 'w'|'d')
without changing the legacy node.writable contract. New CSS rule
.zddc-toast__action styles the inline Elevate button.

Concatenation order: cap.js comes after toast.js + elevation.js so
the dependencies (window.zddc.toast, window.zddc.elevation) are
present at module-load time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:42:05 -05:00
bdd14609d1 build: simplify to stable + exact-version (drop alpha/beta as public concepts)
Releases publish only two things per tool now: a current-stable
canonical symlink and an immutable per-version file. No more channel
mirrors (_stable/_beta/_alpha) and no more partial-version pins
(_v<X.Y>, _v<X>) — those were debt from a release model that never
matched the project's actual usage.

The `./build beta` verb stays, but narrowed: it's an internal SHA
snapshot for the BMC dev chart pipeline (chart's appVersion pins to
"<X.Y.Z>-beta-<sha>" and the chart Dockerfile fetches that SHA from
git). No public artifact on /srv/zddc/releases/. The embedded/* +
chore commit produced by `./build beta` is the actual snapshot.

`./build alpha` is removed entirely.

build/build-lib.sh:
- Drop alpha verb; narrow beta verb to embedded regen + chore commit
- promote_release: stable cut writes <tool>_v<X.Y.Z>.html + <tool>.html
  symlink + <tool>.html.sig companion symlink; beta is a no-op
- promote_zddc_server: same shape — per-version binary +
  per-platform canonical symlink (zddc-server_<plat>) + .sig symlink
- write_zddc_server_stub: singular; emits per-version stubs +
  one canonical zddc-server.html for current stable
- Delete _promote_channel, verify_channel_links, _channel_is_active
- Seed-from-live now copies only per-version files + .sig + pubkey.pem
  (the canonical symlinks get rewritten by this cut; old layout files
  get cleaned by deploy's --delete-after)
- build_releases_index: dropdown simplified to "latest stable +
  pinned versions"; channels-explainer section removed; tool cards +
  CTA URLs point at canonical <tool>.html / zddc-server_<plat>;
  composer emits "stable" sentinel for `apps:` entries
- Fix the acl:{allow:[...]} footgun in the apps_pubkey example

apps.go:
- isValidChannelOrVersion: accept only "stable" + exact X.Y.Z
  (drop alpha/beta and partial pins v0.0/v0)
- normalizeChannel: same
- Resolve URL composition: stable → canonical <prefix>/<app>.html
  (no _stable_ suffix), exact-version → <prefix>/<app>_v<X.Y.Z>.html
- Tests rewritten to match (beta/alpha replaced with v0.0.4 / stable;
  a new TestParseSpec_RejectsLegacyChannelsAndPartialPins locks in
  that the removed forms now error)

browse/build.sh: gate promote_release on $is_release like every other
tool's build.sh (longstanding inconsistency that errored under the new
promote_release case-statement).

freshen-channel: deleted (no channels to freshen).

Net: -254 lines, all green on full `go test ./...`. Dev build verified
via `./build` (no-arg) — new label format "v<next>-dev · <ts> · <sha>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:17:46 -05:00
19566360a6 ui: fix admin-mode frame; drop project-stage strip
Three UI cleanups against the admin/browse chrome.

Red admin-mode frame (shared/elevation.css)
  Was: body { outline: 3px ... ; outline-offset: -3px } — an outline
  doesn't reflow content, so in tools that butt their content to the
  viewport edge (browse split-pane, archive grid) the frame painted
  on top of the first 3px of content.
  Now: body.is-elevated::after { position:fixed; inset:0; border:3px;
  pointer-events:none; z-index:9200 }. The frame lives in its own
  fixed layer above all content, so it never overlaps or steals
  clicks; content layout is unchanged.

Project-stage strip (Archive · Working · Staging · Reviewing)
  Low-value chrome. Removed entirely:
    - delete shared/nav.js + shared/nav.css
    - drop the include from every tool's build.sh
      (browse, transmittal, form, archive, landing, tables, classifier)
    - delete tests/nav.spec.js
    - rebuild tables.html (the //go:embed'd baked-in copy)
  Project navigation already happens through the directory tree in
  browse and the URL bar; the strip duplicated breadcrumb information
  without adding capability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:39:35 -05:00
690d185dc2 feat: reviewing/ lifecycle — Plan Review endpoint, virtual received window, browse context-menu workflows
Two layers shipped together since the second builds on the first.

LAYER 1 — reviewing/ + Plan Review scaffolding

- reviewing/ is now a real folder under each project, populated by the
  Plan Review composite endpoint. The old reviewing/ virtual aggregator
  handler is retired.
- POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op:
  plan-review scaffolds physical workflow folders under reviewing_root
  and staging_root, each carrying .zddc.received_path pointing back at
  the canonical submittal. Idempotent re-runs match by received_path
  and re-converge the ACL.
- Virtual received window: when listing or writing under
  <workflow>/received/, the server resolves through the canonical
  archive/<party>/received/<tracking>/ via the workflow's
  .zddc.received_path. Writes get rewritten to
  <workflow>/<base>+C<n><suffix> so review comments land in the
  workflow folder and never touch the WORM archive.
- Cascade defaults declare on_plan_review per project so the
  reviewing_root and staging_root are configurable.

LAYER 2 — browse context-menu workflows

- Accept Transmittal: right-click a transmittal folder in
  archive/<party>/incoming/ → validates ZDDC folder + filename
  conformance, atomic-renames the folder to
  archive/<party>/received/<tracking>/ (WORM zone), and optionally
  chains into Plan Review in the same composite request. Re-acceptance
  with a different revision merges file-by-file; WORM forbids
  overwrite of an existing filename.
- Stage / Unstage: right-click files in working/<…>/ → "Stage to…"
  with picker of existing staging transmittal folders + inline
  "New transmittal folder…" create; right-click files in
  staging/<…>/ → "Unstage to working/" defaulting to the user's
  working/<email>/ home. Reuses the file-API move primitive.
- Create Transmittal folder: right-click the staging/ pane → prompts
  for a ZDDC-conforming folder name with live validation; mkdir,
  then navigate to the new folder URL where the transmittal tool
  serves the editor.
- Supporting infrastructure: new CanonicalFolderAt cascade lookup +
  X-ZDDC-Canonical-Folder response header so the browse SPA can
  scope-gate menu items without re-implementing the cascade
  client-side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:08:04 -05:00
94b2e29448 feat(browse): SPA overhaul — context menu, YAML editor, icons, hovercard, deep links, autofilter
Major upgrade to the browse tool's UX, plus a few shared modules other
tools can adopt.

User-facing:
- Right-click context menu on tree rows AND empty pane space. Traditional
  file-manager grouping (Open / Download / New / Rename-Delete / Copy /
  Tree ops / View). Items stay visible but disabled when not applicable
  so muscle memory carries. Generic shared/context-menu.js framework
  supports normal items, toggles, submenus, separators, danger styling.
- YAML editor for .yaml / .yml / .zddc files (CodeMirror 5 vendored at
  shared/vendor/codemirror-yaml.min.*). js-yaml lint on every change
  for parse errors. For .zddc cascade files, an additional schema-aware
  lint pass flags unknown keys, bad enum values, and wrong types.
- Per-row drag-drop upload using webkitGetAsEntry (folder uploads work
  recursively). Per-row drop indicator; doc-level overlay still fires
  for blank-space drops at drop_target scopes.
- New folder / New markdown file context-menu items (server mode).
  Rename + Delete with native confirm() dialog. File-API helpers
  removeNode / renameNode use the existing PUT/POST/DELETE endpoints.
- Hover info card with the row's full metadata (ZDDC fields + filesystem
  info + path/URL). Interactive — mouse into it, drag-select text,
  Ctrl/Cmd-C or right-click → Copy. 200ms grace before dismiss.
- Autofilter input at the top of the tree pane. Same grammar as
  archive's column filters (zddc.filter.parse / matches). Filters
  files; folders without matches collapse out. Non-matching folders
  force-open visually when descendants match, without mutating the
  user's actual expand state.
- Two-line ZDDC label: title-first, tracking/rev/status as monospace
  meta below. Icon column anchors to the title line. Chevron is a
  Lucide outline `chevron-right` SVG, rotated 90° on `.expanded`.
- File-type Lucide icon sprite (shared/icons.js — 16 outline glyphs,
  ~5 KB). PDF / Word / Spreadsheet / Slides / Image / Video / Audio /
  CAD / Web / Config / Code / Archive get distinct icons; folders
  tinted with --primary.
- Header wraps gracefully at narrow viewports (shared/base.css
  flex-wrap + title min-width:0 ellipsis). Body becomes flex column
  in browse so a wrapping header doesn't break #appMain height.
- Markdown editor opens in WYSIWYG mode by default. YAML front-matter
  + TOC sidebar reworked: flexbox layout (single visible resizer
  between FM and TOC), both bodies overflow:auto for X+Y scrollbars.
- `?file=<path>` deep links open browse pre-positioned at a specific
  file. Multi-segment paths walk into subdirectories on the way.
  Auto-flips Show hidden when a segment is dot/underscore-prefixed.
- Refresh + show-hidden toggle preserve expansion / selection /
  preview pinning. Path-keyed snapshot survives a re-fetched listing.
- "Add Local Directory" → "Use Local Directory" across the four tools
  that have it (browse, archive, classifier, +transmittal comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:42 -05:00
fb27e47866 fix(browse): bundle shared/zddc-source.js so downloadConverted is available
The markdown editor's DOCX/HTML/PDF download buttons silently no-op'd
because the gate `typeof window.zddc.source.downloadConverted ===
'function'` always failed: browse rolls its own server-mode detection
(state.source === 'server' + node.url) and never needed the shared/
zddc-source.js polyfill before. The new download helper lives on
window.zddc.source, so browse needs to bundle it.

Adds ../shared/zddc-source.js to browse/build.sh's concat_files list,
right after preview-lib.js. Bundle gains ~12 KB; tools that don't
need the polyfill (browse doesn't use HttpDirectoryHandle directly)
pay a small footprint cost in exchange for getting the helper.
2026-05-13 11:14:17 -05:00
141fef88fb feat(browse): "Download (zip)" — pull the current directory's subtree as a zip
A "⤓ Download (zip)" button in the browse toolbar (shown once a
directory is loaded) downloads the directory you're currently
viewing — and everything under it you're allowed to see — as a single
.zip. Navigate into a subfolder first to grab just that subtree.

- Server mode: an <a download> at "<currentPath>?zip=1" — zddc-server
  streams the ACL-filtered zip (see the previous commit), nothing held
  in the browser.
- Offline (file://) mode: new browse/js/download.js walks the picked
  folder with the FS-Access API in two passes — metadata first (so it
  can confirm() before loading >~2000 files / ~500 MB into memory),
  then bytes — bundles with the already-vendored JSZip, and triggers a
  blob download. Hidden entries (".":/"_"-prefixed) are skipped, the
  zip's top level is "<folderName>/…" so it unpacks tidily, and the
  status bar shows progress.

Wired in browse/js/events.js (button click + show/hide alongside the
refresh button); concatenated into browse/build.sh; ARCHITECTURE.md +
AGENTS.md note the ?zip=1 endpoint and the button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:04:04 -05:00
2dc2d032a0 feat(archive,browse): treat .zip transmittal folders as folders + shared zip adapter
New shared/zip-source.js: a ZipDirectoryHandle / ZipFileHandle pair
that exposes a JSZip instance behind the File-System-Access surface
(values/entries/keys, getDirectoryHandle/getFileHandle, getFile) —
read-only, with a zip-slip guard. Mirrors shared/zddc-source.js's
HTTP polyfill. Wired into archive's and browse's build.sh (both
already bundle JSZip).

archive: a .zip whose name minus ".zip" parses as a transmittal-folder
name is now scanned as that transmittal folder. Offline, the zip is
opened in the browser (ZipDirectoryHandle) and its members enumerated
exactly like an uncompressed folder's files — table/export/hash paths
are unchanged (they go through file.handle.getFile()). Online, the
scanner recurses into the server's "<…>.zip/" virtual-directory
listing, so members come back as "<…>.zip/<member>" URLs the server
extracts on demand — no whole-zip download.

browse: the offline (file://) zip path is migrated onto the shared
adapter — expanding a .zip now opens it as a ZipDirectoryHandle and
its members become ordinary dir/file nodes handled by the normal
fetchFsChildren path (nested zips fall out by recursion). The bespoke
flat-entry walker (loadZipChildren / setZipDirChildren / zipEntries /
zipParentId / zipPath / _zipSyntheticDir) is gone — one zip
implementation repo-wide. Markdown members inside a zip are flagged
read-only (the ZipFileHandle refuses createWritable; server "<…>.zip/"
URLs 405 on PUT).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:29:14 -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
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
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
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
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
7ced0395b6 feat(shared): lateral project-stage strip in every tool's header
Adds a thin nav strip directly under the app-header showing the four
canonical lifecycle stages from the transmittal-workflow spec:
archive · working · staging · reviewing. Each is a link to that
stage's directory under the current project. Current stage is
highlighted (bold + primary color, aria-current="page"). Strip
mounts as a sibling of .app-header on DOMContentLoaded — no
template changes needed in any tool.

Render rules (shared/nav.js shouldRender):
- location.protocol must be http: or https: (file:// has no project
  structure to navigate within)
- a project segment must be detectable as the first path segment
  (when it isn't a tool HTML file like /index.html or
  /archive.html?projects=A,B). Multi-project view at the deployment
  root therefore shows no strip.

Stage URL targets:
- Archive   → <project>/archive.html       (project-root archive view)
- Working   → <project>/working/           (directory listing — mdedit auto-served)
- Staging   → <project>/staging/           (directory listing — transmittal auto-served)
- Reviewing → <project>/reviewing/         (directory listing)

Convention-driven, not probed: if a deployment doesn't have one of
these folders the link returns 404. Operators on non-standard layouts
can opt out by setting window.zddc.nav.disabled = true before
DOMContentLoaded.

This pairs with the previous landing-tool change (single-project
click → <project>/archive.html). Together they give the user
both URL-bar manipulation AND visible navigation across the four
canonical project stages.

Five Playwright tests in tests/nav.spec.js exercise:
- non-render at deployment root
- render + active stage on <project>/archive.html
- render + active stage deep inside <project>/working/foo/mdedit.html
- canonical link targets
- mount position is sibling of .app-header

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:50:30 -05:00
8ba029612e feat(shared): non-blocking toast helper available to every tool
Promote classifier's local toast (classifier/css/base.css + showToast
in classifier/js/excel.js) into shared/toast.{js,css}. Every tool's
build.sh now concatenates them, so window.zddc.toast(msg, level, opts)
is callable from any tool.

API:
  window.zddc.toast('Saved.', 'success');
  window.zddc.toast('Could not load: ' + err.message, 'error');
  window.zddc.toast('Note', 'info', { durationMs: 3000 });

Levels: info (default) | success | warning | error. Single-toast
policy — a second call replaces the first. Click anywhere on the
toast to dismiss. ARIA: error → role=alert/aria-live=assertive,
others → role=status/aria-live=polite.

Class prefix is .zddc-toast (BEM-ish) to avoid colliding with any
tool-local .toast rules. Classifier's existing showToast now
delegates to window.zddc.toast — call sites in excel.js +
selection.js are unchanged. Classifier's local .toast CSS block
deleted in favor of the shared one.

This commit only EXPOSES the API. Replacing the ~25 alert() call
sites scattered across archive/transmittal/mdedit/classifier with
toast calls is left as follow-up — each alert needs per-call review
to decide if it's truly non-blocking.

Five Playwright tests in tests/toast.spec.js lock the contract:
API exposure, level mapping, ARIA roles, single-toast replace,
click-to-dismiss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:04:41 -05:00
22c142e45a chore(headers): standardize across all 7 tools
Bring every tool's header in line with archive's pattern:

  [logo] [title] [version] [Add Local Directory] [⟳] ............... [◐] [?]
  ------------- header-left ---------------       ----- header-right -

Changes per tool:

* browse: rename "Select Directory" → "Add Local Directory"; add the
  red-non-stable wrap to the build label (was missing); add a help
  panel + bundle shared/help.js.

* classifier: rename selectDirectoryBtn → addDirectoryBtn,
  refreshBtn → refreshHeaderBtn for consistency. Update all JS
  callers and welcome-screen copy to the new label.

* mdedit: same id rename. Move the previously-in-pane refresh
  button into the header. Stop renaming the dir button to
  "Directory: <name>" once a folder is loaded — instead use the
  shared btn--subtle variant to de-emphasize while keeping the
  standard label.

* transmittal: convert non-standard <div class="app-header"> with
  spacer/icons containers to <header class="app-header"> with the
  canonical header-left/header-right pair. Move the publish split-
  button into header-left (Transmittal-specific primary action).
  Remove dead .app-header__spacer/__icons/header-icon-btn CSS now
  that nothing references those classes.

* landing, form: add help-btn + help-panel + bundle shared/help.js.
  Each panel is tool-specific (project picker docs for landing,
  schema-driven form docs for form).

Cross-cutting:

* shared/base.css: promote .btn--subtle from browse/css/tree.css
  so any tool with an online mode can de-emphasize Add Local
  Directory consistently.

Verified all 7 tools in headless Chromium: header structure correct,
build label red on non-stable cuts, help panel opens + closes via
button + Esc.
2026-05-04 07:49:17 -05:00
582db6d86d feat(browse): vendored JSZip, SVG home icon, auto-filter rows
- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
  the browse build instead of CDN-loading. Eliminates the failure
  mode where ZIP rows can't expand because the CDN script doesn't
  load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
  spreadsheet-style auto-filter rows in <thead>:
    - 📄 row: file-name filter + extension filter
    - 📁 row: folder-name filter
  Each input uses shared/zddc-filter syntax (substring/!negate/
  ^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
    - file matches keep their ancestor folders visible (path-to-hit)
    - folder match keeps its descendants visible
    - filters compose (file ∧ folder ∧ ext) so combinations narrow
  Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
  that tints with currentColor.
2026-05-03 21:35:15 -05:00
424bf8e769 feat(browse): Phase 2 — preview popup, ZIP expansion, ext filter, breadcrumbs
Bundles Phase 2 polish + the user-requested header/breadcrumb work:

- Breadcrumbs replacing the plain currentPath span. Server mode
  renders linkified ancestor segments (each <a> navigates to that
  directory; the browser fetches browse.html, the new instance
  auto-loads the listing). FS-API mode renders the rootHandle name
  as a non-link (no ancestor handles to navigate). Both prefix the
  path with a 🏠 root icon. Trailing slash + bold-current segment
  match common file-explorer conventions.

- Subdued 'Select Directory' button in server mode. Once browse is
  serving a real directory listing, the local-folder switcher is
  available but visually quiet (btn--subtle: transparent, muted
  color). FS-API mode keeps the primary styling (it's how the user
  got there). New btn--subtle CSS class added to browse's tree.css.
  A refresh button (⟳) appears next to it in both modes; clicking
  it re-fetches the current root listing.

- Header consistency: browse now matches archive's header layout
  (refresh + help buttons in addition to theme on the right). Help
  is a placeholder for future help dialog wiring.

- File preview popup. Click a file row → opens a popup window with
  the file rendered. Plain types (PDF, HTML, image) load in
  iframes; TIFF + ZIP listings via shared/preview-lib.js's
  renderTiff / renderZipListing helpers; text via <pre>; unknown
  types → 'click Download' placeholder. Modifier-click (ctrl/cmd/
  shift) and middle-click still open the file in a new tab via the
  underlying <a target=_blank>. Single popup window is reused
  across multiple file clicks (matches archive's UX).

- ZIP inline expansion. .zip files have a chevron and act like
  folders in the tree. First expand fetches the zip bytes
  (server URL or FS handle or parent-zip read), parses with JSZip
  (auto-loaded from CDN), and synthesizes the entry tree. Nested
  directories within the zip lazy-expand on demand by re-walking
  the cached entry list at the right path prefix. Click on a
  zip-entry file opens the preview popup with bytes read from
  JSZip. Recursive expand-all skips zip archives by design — they
  can be very large, and explicit click-to-expand is safer.

- Extension multi-select filter. Toolbar now has a <select
  multiple> populated with extensions present in the current
  view. Filter is OR-of-selected; combined with the name filter
  it's AND-of-both. Folders pass through (so expanding a folder
  whose name doesn't match the ext filter still shows its file
  children that do match).
2026-05-03 20:39:49 -05:00
fb13ff4fd8 feat(browse): generic directory listing tool — default at folder URLs
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.

Modes (auto-detected at page load):
  - Online: when served by zddc-server at a folder URL, queries
    the same URL with Accept: application/json to load the listing
    and renders it. Auto-served as the default at any directory
    under ZDDC_ROOT without an index.html (replacing the previous
    minimal-HTML stub from directory.go).
  - Local: 'Select Directory' button uses FileSystemAccessAPI to
    pick any folder on disk; works in Chromium-based browsers.

Features (Phase 1 — what's in this commit):
  - Tree view with lazy-loaded folders (children fetched on first
    expand).
  - Sort by name / size / extension / date (column header click).
  - Filter by name substring (toolbar input).
  - File click opens in a new tab — for server-backed pages,
    routes through zddc-server's normal handler so .archive
    redirects + apps cascade overrides + ACL all apply.

Phase 2 deferred:
  - ZIP files inline expansion (treat archive entries as virtual
    children).
  - File preview popup (reuse shared/preview-lib.js).
  - Extension multi-select filter.

Wiring:
  - browse/ added to top-level ./build's per-tool list, embed
    block, versions.txt, and the lockstep release commit + tag set.
    All seven tools (archive, transmittal, classifier, mdedit,
    landing, form, browse) advance together on stable cuts.
  - shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
    verify_channel_links's per-tool loop.
  - zddc/internal/apps/embed.go: //go:embed browse.html +
    EmbeddedBytes("browse") case.
  - zddc/internal/apps/availability.go: browse available at every
    directory (same as archive).
  - zddc/internal/apps/handler.go: MatchAppHTML routes
    /<dir>/browse.html → 'browse'.
  - zddc/internal/handler/directory.go: when a directory request
    arrives with Accept: text/html and no index.html exists,
    serve the embedded browse.html bytes (with a JSON-fallback
    if the embedded slot is empty during bootstrap).
2026-05-03 19:56:51 -05:00