Commit graph

66 commits

Author SHA1 Message Date
bc762a7d74 feat(classifier): rename transmittals; remove/move files already in one
By-transmittal pane gains the editing it was missing:
- Rename a transmittal after creation (✎ on the bin → prompt). The name IS the
  filing folder (deriveTarget reads bin.name), so renaming changes where copies
  land, not just the label.
- Each placed file row is now draggable — drop it on another transmittal to MOVE
  it — and carries an ✕ to remove it from the transmittal (clears that axis).
  previewFromTarget now lets action buttons through so ✕ doesn't trigger preview.

Test: rename feeds the derived folder; the row is draggable + has remove; ✕
clears the transmittal; re-place moves it to another bin (55 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:12:38 -05:00
d951c3a5e7 feat(classifier): hash-check same-name files; verify every copy; resume converges
Same tracking number + revision ⇒ same document, so the bytes must match:
- Pre-flight (and a new "Check" button) groups fully-classified files by their
  canonical name and SHA-256s any collisions. Identical bytes collapse to ONE
  copy (deduped); DIFFERENT bytes are a conflict — flagged ≠ in red in the
  By-tracking table (with a tooltip) and held back from the copy so the user
  fixes them first. Flags clear when a placement changes.
- Every file copied this run is VERIFIED: read the written target back, compare
  SHA-256 to the source. One re-copy attempt on mismatch; if it still fails, the
  bad target is removed so a re-run re-copies it — resume converges on a
  fully-correct archive (skip-if-exists stays the fast path for good files).

classify gains transient hash-conflict flags (setHashConflicts/hasHashConflict),
copy gains sourceSha (cached), writeTarget, verifyOne, removeTarget, resolvePlan
and audit(); copyTo runs the verify pass and reports verifyFailed.

Tests: identical pair dedups + differing pair conflicts/flags; a corrupting
write fails verification and is removed (54 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:01:02 -05:00
61c1b4f90d feat(classifier): server copy picks any accessible project's archive
Replaces the raw archive-URL prompt with a project picker: copyToServer fetches
the caller's projects from /.profile/access and lets them choose one; files are
PUT under <project>/archive/<party>/<received|issued>/<transmittal>/. Write
permission is enforced server-side, so a 403 surfaces per file (and resume
retries). Same resumable engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:53:37 -05:00
144baeec61 feat(classifier): Copy → server archive (HTTP) or local folder, resumable
Replaces the zip option. Copy now offers two real destinations, both filing
under <party>/<received|issued>/<transmittal>/<name>:
- Server archive — PUTs each file into a zddc-server over HTTP via the
  zddc-source HttpDirectoryHandle (mkdir as needed). Offered only over http(s);
  prompts for the archive URL (guessed from the current path's archive/ segment).
- Local folder — the File System Access picker (choose archive/ to file directly).

Both reuse the one copy engine and are efficiently resumable: copyOne probes the
target with a cheap stat/HEAD and SKIPS anything already present — no source
read, no hashing — so a re-run after an interruption only does what's left.
Canonical ZDDC names + the WORM archive mean an existing name is the same
document, so we never overwrite (the old content-diff path is dropped).

Tests: re-run skips an existing local target; PUT into a server-style handle then
resume-skips it (52 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:43:53 -05:00
325dce9f3e feat(classifier): Copy prompts download .zip or copy to a directory
Copy now opens a small dialog: copy the canonical files into a folder (pick your
archive/ to file them straight into <party>/<received|issued>/<transmittal>/),
or download a single .zip with the same layout (unzips into place). The zip path
reuses readSource + the derived outRel, so the bytes and folder structure match
the directory copy exactly; conflicts are still skipped beforehand.

Test: the zip is built with one entry at ClientCorp/received/<transmittal>/
ACME-MECH-0001_A (IFR) - foundation.pdf (52 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:31:35 -05:00
645b308ebc feat(classifier): "Partial" Show filter — assigned in the other tab only
Adds a fourth source-tree bucket relative to the active axis: a file assigned on
the OTHER axis but not this one. With its own "Show Partial" toggle (and count)
you can assign a batch in one tab, switch tabs, show only Partial, and finish
them off — working in chunks across the two axes.

fileCategory now returns excluded / assigned (this axis) / partial (other axis
only) / unassigned (neither); filters, counts and the toolbar checkbox follow.

Test: a tracking-only file reads as Partial on the transmittal tab and hides
when Partial is unchecked (51 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:27:39 -05:00
823cfb0d48 fix(classifier): pin the xlsx preview's horizontal scrollbar to the window
The sheet rendered into #previewContent (flex:1) with an inner table scroller
also set to flex:1 — but #previewContent wasn't a flex column, so the inner
flex:1 was ignored and the scroller grew to the full sheet height, dropping the
horizontal scrollbar to the very bottom of the content. Make #previewContent a
height-constrained flex column (and give the scroller min-height:0) so the table
area fills the viewport and its h-scrollbar sits at the window bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:34:43 -05:00
4b79386c4e ux(classifier): drop the hover "Extract All" button from folder rows
It wasn't needed and its margin-left:auto shoved the folder's direct/total stats
out of position. Removed the button and its now-unused helpers
(countZipDescendants / getZipDescendants / handleExtractAllZips) and CSS. The
per-archive "Extract" on an expanded zip-root stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:18:31 -05:00
800454d0db ux(classifier): revision cell previews its file; drop the count bubble
In the By-tracking table the revision name is now a preview link for its placed
file (clicking it opens the preview; hover names the original), and the per-cell
count bubble is gone — a revision is one document, so the number was noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:11:39 -05:00
1631e2b8ca feat(classifier): By-tracking as a merged-cell table (pattern config + sticky)
The nested tracking tree wasted huge vertical space showing the hierarchy by
indentation. Render it as a spreadsheet-style table instead: each configured
field is a column, ancestor cells span (rowspan) their descendants' rows, the
revision gets its own aligned column, and each placed file is a row. Far denser,
and the hierarchy reads left-to-right.

- classify.js: a per-workspace pattern config { trackingFields[{name,optional}],
  statuses, modifiers }, default ORIG·PROJ·DISC·TYPE·SEQ + optional SUFFIX,
  seeded statuses from the shared enum. Serialized with the workspace; reset
  keeps it (it's a setting, not data). getConfig/setConfig/getTrackingFields.
- target-tree.js: tree → flat rows → merged-cell <table>. A revision leaf is
  detected by its "(STATUS)" suffix, so SEQ "0001" and revision "A (IFR)" land
  in the right columns. Drop targets, CRUD, editable ZDDC name, validation,
  filter and cross-reveal all preserved on the cells.
- css: sticky header + sticky merged-cell values (the value stays pinned under
  the header while you scroll a tall group, so a span never reads blank).
- The original filename moves to a hover tooltip on the editable name.

Next: the revision context-menu (status / draft ~ / +Cn toggles + stepper) and
the settings UI to edit the pattern/status/modifier lists.

Tests: merged ancestors get the right rowspan; revisions align in one column;
the date revision stays intact (49 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:56:00 -05:00
5f1df08077 fix(classifier): keep everything after "_" as one leaf when parsing a path
parseFolderLevels split the whole string on "-" and only looked for "_" in the
last segment, so a date revision shattered: "LKU-123456-PM-SCH-0001_2025-11-17
(IFI)" became …/0001_2025/11/17 (IFI). Split on the "_" first instead — the
tracking number (before "_") nests by "-", and everything AFTER the "_" is one
leaf kept whole, hyphens and all.

Test: the date-revision path yields …/0001/"2025-11-17 (IFI)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:56:12 -05:00
1f8b4e4aaa feat(classifier): zips are single files by default, toggle to expand as a folder
Previously every .zip was auto-expanded into a folder of members (and was even
double-represented as both a file and a folder node). Now a .zip is one
classifiable file by default; right-click it → "Expand as folder" to pull its
members into the fileset, and right-click an expanded archive (or a member) →
"Collapse to single file" to go back. The toggle sits with Exclude in the
context menu.

- scanner: stop creating zip-root nodes during the scan; expandZipAsFolder /
  collapseZipToFile mutate the tree in place (re-reading members from the live
  handle or, for a restored workspace, lazily from the root) and recompute
  subtree totals. Mode is encoded by the tree shape, so it persists in the
  snapshot as-is.
- classify.dropAssignments clears the assignments that cease to exist when a zip
  flips mode (the single-file key on expand; the member keys on collapse).
- copy already handles both: a zip-as-file copies whole; members extract from
  the archive.

Also: a folder whose entire subtree is excluded now renders its name struck
through, mirroring the excluded-file style.

Tests: collapse restores the single .zip + drops member assignments; a
fully-excluded folder gets the struck-through class (48 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:34:25 -05:00
e2c2d1571d ux(classifier): relabel the two JSON surfaces so their purpose is clear
Two exports for two different consumers were both just "Export". Name them:
- Classification (header, AI round-trip): "Export for editing" / "Import edits"
  — a filename-per-file JSON with no scanned tree, meant to be hand/AI-edited
  and re-imported. Download suffix is now .zddc-classification.json.
- Workspace (welcome screen, transfer/backup): "Import workspace" + a row
  "Export" tooltip spelling out it carries the snapshot + classifications for
  moving between browsers. Download suffix stays .zddc-workspace.json.
Each tooltip points at the other so they're not confused.

Also wire workspace.activeName (referenced by the dataset export but never
exported), so a classification file is named after its workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:56:09 -05:00
533b830d2c feat(classifier): export/import a whole workspace; fix short-viewport welcome
The scan is the expensive part (minutes on cloud mounts), so a workspace is now
portable between browsers/machines without re-scanning:
- Per-workspace "Export" downloads the snapshot + classify map as one
  <name>.zddc-workspace.json (the source-directory handle is omitted — it can't
  be serialized across browsers).
- Landing-page "Import" recreates the workspace from that JSON; the user clicks
  "Connect directory" once on the new browser to re-attach the folder (no
  re-scan — the snapshot carries the 2-hour walk). A classification-dataset JSON
  is rejected with a pointer to the in-app Import.

Also fix the welcome screen clipping its top on short viewports: the base
.empty-state centers with align-items:center, which overflows symmetrically and
puts the card's top out of scroll reach. Center the inner card with auto margins
instead — they collapse when it's taller than the viewport, keeping the top
reachable.

Test: workspace import recreates a transferable record (snapshot + map, no handle); 46 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:50:58 -05:00
203674ee4c feat(classifier): persist & copy files inside .zip archives
Zip members were live-only: expandable while the source was connected, but the
workspace snapshot dropped the archive (.zip became a plain file), so a
classification made inside one vanished on reopen — and copy couldn't extract it
anyway (it tried to walk the archive path as a real directory).

Now zips are first-class:
- snapshotTree/loadSnapshot persist the scanned archive subtree — zip-root +
  virtual folders + members carry isVirtual/zipPath/zipEntryPath, so the tree
  rebuilds on reopen and assignments inside an archive survive. An archive that
  was never opened persists as a lazy 'zip' node that reopens on demand.
- scanner.ensureZipLoaded(rootHandle, zipPath) reloads an archive from the
  workspace root when the in-memory cache is cold (post-restore); scanZipNode
  falls back to it when a restored zip node has no live file object.
- copy.js reads a member via scanner.extractZipMember (Blob from the archive)
  instead of a non-existent file handle; preview.js reloads the archive for a
  restored member before opening it.

This also reconciles export/import with the snapshot: both now keep zip members,
so a round-trip no longer leaves dangling in-archive assignments.

Tests: zip subtree snapshot round-trip; copy extracts a member to the output (45).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:27:00 -05:00
e1c479dba5 fix(classifier): search opens only the path to a hit, not the whole tree
Searching no longer force-expands every folder. computeVisible now returns an
"open" set holding just the connector folders on the path down to each match;
those open to expose the hit, while off-path branches and terminal nodes keep
their real collapse state (and honest ▶/▼ arrows). Reshaping the tree is the
user's call — the root's expand-all is one click away.

Test: a deep file hit opens its branch and leaves the sibling collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:10:27 -05:00
15ce7098a0 fix(classifier): Show toggles preserve tree state; add Reset to raw input
- Toggling Show Unassigned/Assigned/Excluded/Empty no longer force-expands the
  whole tree. Auto-expand is now reserved for the NAME search (where revealing a
  match means expanding to it); the Show toggles only hide/show, leaving your
  expand/collapse state untouched. Also preserve scrollTop across re-render so a
  toggle doesn't jump the view to the top.
- Add a "Reset" button (danger-styled, beside Export/Import) that discards every
  classification — tracking + transmittal trees, assignments, excludes, title
  overrides — and returns to just the raw scanned input. Your files are never
  touched. Destructive + irreversible, so it confirms with an "Export first"
  warning and no-ops (info toast) when there's nothing to reset.

Tests: Show toggle preserves collapse vs. name-search auto-expand (classify 42).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:02:12 -05:00
aa34a4b3e7 feat(classifier): Show Empty toggle; tidy the folder-tree header
- Add a "Show Empty" checkbox (classify mode) — when off, folders whose whole
  subtree contains no files are hidden, decluttering messy scans.
- Move the Show Unassigned/Assigned/Excluded/Empty filters out of the cramped
  pane header into a dedicated "Show …" toolbar row beneath it (wraps cleanly).
- Drop the "X folders selected" text from the folder-tree header (selection
  still works; updateSelectedCount guards the now-absent element).

Test: Show Empty off hides file-less folders (classify.spec.js -> 41).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:52:03 -05:00
c61cac7c8f feat(classifier): live filter box above each file tree (reveals matches + path)
Adds an autofilter input above the source tree and above each target tree.
Typing substring-matches (ANDing space-separated terms) against the full file
path/name (and folder/node names) and reveals every match with the folder
hierarchy leading to it — non-matching branches collapse out, matching branches
auto-expand. So you can type "master deliverables list" and jump straight to it.

- Source tree (tree.js): one-pass visible-set over path+name; composes with the
  Show Unassigned/Assigned/Excluded toggles; auto-expands to reveal hits.
- Target trees (target-tree.js): tracking + transmittal nodes are filter-aware
  (match node names + each placed file's original/derived name); one shared
  query mirrored across both tab inputs.

Tests: source-tree path reveal + tracking-tree node filter (classify.spec.js -> 36).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:37:36 -05:00
9851cc4463 feat(classifier): switch dataset export/import to a filename-per-file format
Replaces the ID-based dataset export/import (which required an external editor
to build a nested tree and keep node ids consistent) with a flat, AI-friendly
list: one record per input file carrying its full ZDDC filename — and an
optional transmittal {party, slot, date, type, seq, status, title}.

- Export: one {source, originalName, filename, excluded, transmittal?} record
  per source file (filename = the derived ZDDC name, "" if unassigned).
- Import: parses each filename and rebuilds the tracking tree (parseFolderLevels
  + addTrackingPath, sharing ancestors); excluded files are marked; transmittals
  are reconstructed with party/bin dedup. No node ids for the editor to manage.

New classify helpers: transmittalRecord (export), findOrAddParty /
findOrAddTransmittalBin (import dedup). serialize/load stay for workspace
persistence. Test rewritten for the filename round-trip (classify.spec.js -> 34).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:52:44 -05:00
4425a599f0 feat(classifier): export/import the classification dataset as JSON
Adds Export / Import buttons to the Classify & Copy header so the full dataset
(tracking + transmittal trees, per-file assignments, output name) round-trips
through a JSON file — export it, edit externally (e.g. with an AI), re-import.

- Export downloads a self-documenting JSON (canonical classify.serialize() state
  + an informational sourceFiles inventory + a _format note). Lossless: empty
  tree branches and unassigned state survive.
- Import validates, confirms before replacing a non-empty current dataset, and
  loads via classify.load() (ignores the wrapper/_format/sourceFiles keys).

Test: serialize → JSON → load preserves trees (incl. an empty branch) +
assignments (classify.spec.js -> 34 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:18:34 -05:00
139171481e feat(classifier): three-state filters, expand/collapse-all, drop-prompt, preview + editable filenames
Classify & Copy interaction pass (replaces the single "Hide Assigned" toggle):

- Source-tree filters: three "Show Unassigned / Show Assigned / Show Excluded"
  checkboxes (classify mode only) with live per-tab counts; "Hide Compliant" is
  now rename-mode only. Folders with nothing visible collapse out.
- Target tree: ctrl/cmd-click a toggle to expand/collapse the whole subtree.
- Tracking drop-to-any-level: dropping on a node that isn't already a complete
  leaf prompts for the remaining levels (e.g. "0001_0 (IFU)"), which are parsed
  and nested under the drop target. Dropping on a finished leaf assigns directly.
- Placed-file rows: click to preview; the derived filename is now an inline
  input — edit it (full "TRACKING_REV (STATUS) - Title.ext") and the item is
  re-filed onto the parsed tracking path (created if needed) + title override.

New classify helpers: trackingNodeComplete, trackingPathLabel. tree.setShowFilters
replaces setHideAssigned. Tests updated/added (classify.spec.js -> 33 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:08:30 -05:00
055f4cf4e0 fix(classifier): parse add-folder names into nested levels; controls back to right/hover
Follow-up to the Classify & Copy add-folder work:

- Add-folder now parses each (brace-expanded) name into the nested tracking
  levels it represents — split on "-", then the FINAL "_" splits the leaf
  revision. "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU); a braced pattern nests
  every expansion and shares common ancestors. New classify.parseFolderLevels
  + addTrackingPath (ensure-path with name reuse).
- Node add/edit/delete controls moved back to the RIGHT of the level name and
  revealed on hover (was left + always-visible).

Tests: parseFolderLevels cases + a nested-chain/shared-ancestor test; updated
the "+ Root folder" test for the new nesting (classify.spec.js -> 31 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:58:42 -05:00
8f839fc0c9 feat(classifier): Hide Assigned filter, left-aligned node controls, brace-expand add
Classify & Copy polish — in either target tab the goal is to assign or exclude
every left-pane file until nothing remains:

- Hide Assigned checkbox (classify mode, in the folder-tree pane header):
  collapses the source tree to only what's left on the ACTIVE axis — hides
  files already assigned in the current tab (or excluded) and any folder whose
  scanned subtree is thereby empty. Re-renders on tab switch; target-tree
  exposes activeAxis().
- Node add/edit/delete controls moved to the LEFT of the level name and made
  always-visible (was right-aligned + hover-only), so building/pruning the
  tracking and transmittal trees is one click.
- Brace expansion in the add-folder box: "BMB-187023-{PM,EL,EM}-MOM-
  {0001-0002,0005}_A (IFR)" creates all 9 folders — {a,b} alternation +
  {N-M} zero-padded numeric ranges, cartesian product across groups; a
  multi-create is confirmed first. New classify.expandFolderPattern().

Tests: expandFolderPattern unit cases + a Hide-Assigned DOM test
(classify.spec.js → 29 passed; classifier.spec.js → 4 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:31:14 -05:00
01b01f8f7a feat(classifier): welcome rewrite + resumable scan + reconnect on restore
- Welcome: drop the 'absorbed into Browse' notice; bigger, inviting intro with
  a two-method tutorial (Classify & copy — recommended/non-destructive; Rename
  in place — edits files) and a OneDrive 'keep on device' tip.
- Resumable scan: the snapshot now records per-folder scan state, the workspace
  record is created up front, and the partial snapshot is persisted every 5s
  during the (slow) scan. scanner.resumeScan() resolves handles for only the
  still-pending folders and drains them — so an interrupted scan picks up where
  it left off instead of starting over.
- Reconnect on restore: opening a workspace no longer assumes the source is
  connected; a header 'Connect directory' button (and a prompt) re-grants the
  persisted handle in one click or lets you re-pick it. Until connected you can
  still edit the data model; connecting also resumes any pending scan.
- Tests: resume-scan via mock root handle (31 classify/classifier green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:38:08 -05:00
975c804cc7 feat(classifier): click-to-preview in Classify & Copy mode
Identifying a file is half the workflow — you preview it to see what it is,
then assign its tracking number by drag. Preview was only wired into the old
Rename grid; in Classify & Copy a source file now previews on single-click
(drag still assigns, right-click excludes). preview.previewFile() resolves a
snapshot file's handle from the workspace root (one-click read re-grant) before
opening, so it works for resumed workspaces too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:08:57 -05:00
afcba81e61 ux(classifier): default to Classify & copy; relabel the mode toggle
The top-level toggle is a tool choice, not the two classification axes (those
are the By-tracking / By-transmittal tabs inside Classify & Copy). Default to
the Classify & copy workflow and relabel the toggle 'Classify & copy' /
'Rename in place' so its purpose is clear; the in-place spreadsheet stays one
click away. 'Use Local Directory' now opens in Classify mode too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:26 -05:00
eb07e7622d fix(classifier): file-only folders are expandable in Classify mode
A folder with files but no subfolders got no expand toggle, so in Classify &
Copy mode its files (the drag source) could never be revealed — and leaf
folders full of files are exactly where the work is. Make a folder expandable
when it has files in classify mode; expanding lists the draggable file rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:26 -05:00
1d09abdc8b feat(classifier): workspaces — scan-once, resume from snapshot (phase 6)
The classifier re-scanned the source on every session; on cloud-backed mounts
(OneDrive/Samba) that's minutes of per-op latency. Workspaces fix it: scan a
folder ONCE, snapshot the completed tree, and resume instantly — all
classification runs on the data model; the filesystem is only touched at copy.

- persist.js v2: multi-workspace IndexedDB (tiny 'index' store for the welcome
  list + 'data' store holding the source handle, tree snapshot, and map). DB v2.
- scanner.js: snapshotTree()/loadSnapshot() (compact, handle-less, marked done,
  totals recomputed) + lazy resolveFileHandle/resolveDirHandle from the root.
- workspace.js: welcome manager (new/open/rename/delete), debounced autosave of
  the active workspace, 'Refresh from disk' (re-scan → re-snapshot, path-keyed
  map carries over). New workspace = the one slow full scan; reopen = instant.
- copy.js: resolves snapshot files' handles from the workspace root with a
  one-click read permission re-grant; missing-on-disk files surface as errors.
- app.js: enterAppShell() shared by rename/workspace flows; exposes setMode;
  classify.js decoupled from persistence.
- template/css: welcome workspace list + header 'Workspaces' button.
- tests: snapshot round-trip, persist CRUD + classify-only-preserves-tree,
  copy-from-snapshot via mock root handle (28 classify/classifier tests green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:07:40 -05:00
05fc3b69dd perf(classifier): raise scan concurrency 16 -> 32
The scan is I/O-bound on cloud-sync / network mounts (OneDrive, Samba) where
each directory read is a high-latency round-trip. More in-flight reads hide
that latency on the many-folders case. (A single large folder is still
enumerated one entry at a time by the File System Access API and can't be
parallelized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:08:54 -05:00
420f735e89 feat(classifier): copy-out with duplicate detection + map restore (phase 5)
The Copy button (enabled once >=1 file is fully classified) copies the mapped
files into a user-chosen output directory under their canonical names/layout
<party>/{received,issued}/<transmittal>/<filename> — reading the source, never
writing it.

- copy.js: plan() (complete, non-excluded files) → conflict scan (two sources
  → same output path are reported + skipped) → copyTo() engine on the generic
  FS-Access shape (ensureDir + getFileHandle + createWritable). Per-file dedup:
  identical target (sha256) is skipped; existing-but-different is left
  untouched and reported; live footer progress; completion toast.
- app.js: restores the saved map on launch (keyed by source-relative path, so
  it re-attaches when the same directory is re-opened) and persists the source
  handle on open; Copy button wired.
- target-tree.js: enables/labels the Copy button from the done count.
- 2 copy-engine tests with mock FS handles (copy/skip/differ + conflict);
  24 classify+classifier tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:37:44 -05:00
eb1e3ec948 feat(classifier): left-tree markers, exclude, cross-tree find (phase 4)
- Each source file row shows a classification state dot (unassigned →
  has-tracking/transmittal → done), and each folder shows an aggregate dot
  over its subtree.
- Right-click a file or folder to Exclude/Include from the copy (folder applies
  to its whole subtree) or clear an axis; excluded files are struck through and
  never copied.
- Cross-tree find is bidirectional: click a placed file in the target pane to
  reveal+flash it in the source tree (expanding its folders); click a source
  file to switch the target pane to its placed axis and flash the node.
- Target pane now reverse-looks-up over ALL scanned files (the left tree), not
  the selection-scoped grid, with placements grouped in one pass per render.
- classify.getAssignment() read-only accessor; 5 new tests (18 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:32:42 -05:00
47cf58b0e9 feat(classifier): drag-and-drop assignment (phase 3)
In Classify & Copy mode the left tree now lists each folder's files as
draggable rows (with a classification state dot), and folder rows are
draggable for a group-drag of the whole subtree. Target-tree nodes are drop
zones: a tracking folder (any node) or a transmittal bin; dropping assigns the
dragged source key(s) along that axis via classify.place().

- dnd.js: drag-payload bus (keys held in a module var since dataTransfer can't
  be read during dragover; carries a marker for the copy cursor).
- tree.js: createFileElement + group-drag dragstart; classify-mode file rows.
- target-tree.js: setupDropZone with dragover highlight + drop assignment
  (tracking = any node, transmittal = bins only).
- app.js: source tree re-renders on classify state change.
- 2 DnD drop-handler tests (14 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:23:38 -05:00
a8403d1f73 feat(classifier): mode toggle + dual-pane target trees (phase 2)
Header gets a Rename / Classify & Copy switch. In Classify & Copy mode the
spreadsheet pane is replaced by a tabbed target pane (By tracking number /
By transmittal), while the source tree stays on the left.

- target-tree.js: renders both trees from classify state; tracking-folder
  create/rename/delete (leaf folders styled as the revision); party CRUD +
  per-slot inline transmittal-bin form (date + TRN/SUB + seq + optional
  status/title); shows the derived filename + a validation badge for each
  placed file; live header stats (done / in progress / unassigned / excluded).
- app.js setMode(): swaps panes, toggles classify mode, re-renders both trees.
- 3 UI smoke tests added to classify.spec.js (12 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:19:35 -05:00
a8f116734d feat(classifier): Classify & Copy state model + persistence (phase 1)
Foundation for the non-destructive map+copy workflow: source stays read-only,
files are mapped onto two orthogonal target trees, a later step copies renamed
copies to a separate output dir.

- classify.js: the single source of truth. assignments map keyed by
  source-relative path (survives re-pick); tracking tree (positional: ancestors
  joined '-' = tracking number, immediate parent 'REV (STATUS)' leaf = rev+status,
  title from original name) and transmittal tree (<party>/{received,issued}/<bin>).
  deriveTarget() computes filename + output path + validation purely; pub/sub +
  debounced autosave; node CRUD with dangling-placement cleanup.
- persist.js: IndexedDB store of the serialized map + the source
  FileSystemDirectoryHandle, with queryPermission/requestPermission re-grant on
  reload and a re-pick fallback.
- tests/classify.spec.js: 9 in-page unit tests for the derive/assignment logic
  (no FS Access needed) — tracking join, leaf REV (STATUS) parse incl. invalid
  status, title derivation/override, transmittal path composition, exclude,
  cascade delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:11:04 -05:00
389b2e94ac ux(classifier): blue completed counts; blue labels when row fully scanned
The black-completed vs grey-flashing distinction was too subtle. Completed
numbers (the direct count, always; the +total once final) now render in
var(--primary) — theme-aware blue in both light and dark. While a subtree
is still scanning its +total stays muted grey + pulses, so blue = done,
grey = in progress. Once both numbers are blue the row's folders/files
labels turn blue too (.folder-count.done .ct-label).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:11:51 -05:00
e0ba77a75b perf+ux(classifier): continuous 16-way scan pool; accurate FS error text
Scan concurrency: the scan is I/O-bound — each directory read is a network
round-trip to the share, so the lever is parallel in-flight reads, not CPU
threads. Replace the per-level BFS barrier (which idled workers waiting on
the slowest dir in each level) with a continuous shared-queue pool that
keeps up to SCAN_CONCURRENCY (16, up from 6) reads in flight at once,
pulling newly discovered child dirs as they land. Still roughly
breadth-first (FIFO), so top levels surface first. ensureScanned reuses it.

Error messages: translate File System Access DOMExceptions into accurate,
actionable text keyed on err.name (not the cryptic raw message, which reads
like a permission problem when it isn't). e.g. InvalidStateError now reads
'the folder changed on disk since it was first read … rescan' instead of
'an operation that depends on state cached in an interface object …'. The
raw name+message is appended in parens for copy-paste troubleshooting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:09:14 -05:00
caff489206 perf(classifier): scan is a pure listing — no getFile() per file; lazy zips
The scan was slow because it OPENED every file (getFile() for size/lastModified
— which the grid doesn't even display) and read every ZIP inline. On a network
share that's a round-trip per file. Now:

- createFileObject builds rows from the directory entry name alone, no
  getFile(); size/lastModified load on demand (preview/SHA/rename already call
  getFile() themselves). The scan is now a pure directory listing.
- ZIPs are lazy: a .zip is an expandable node read only when opened
  (scanZipNode), not during the walk.
- Footer shows live elapsed time (ticks every second), and a success toast
  fires at completion with totals: "Scan complete — N folders, M files in Ts."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:55:29 -05:00
28bfcc6e8c feat(classifier): move scan status to a page footer
The live "Scanning… N folders · M files — <path>" status now lives in a
persistent page footer bar instead of under the folder-tree header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:38:09 -05:00
3d02084397 feat(classifier): direct+total counts in tree; toast scan errors
- Counts now read "direct+total" — e.g. "(2+10 folders, 15+300 files)". The
  direct number (immediate children) shows as soon as a folder's own directory
  is read; the total (whole-subtree) is accumulated progressively and flashes
  grey until the subtree is fully scanned, then goes solid. The "+total" is
  omitted once done and there's nothing deeper.
- Scan errors (permission denied, network hiccups on a share) now surface as a
  toast (de-duped per path) instead of only console noise; a failed folder/zip
  is marked done-empty so it doesn't wedge the walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:28:01 -05:00
ecb0a270cc feat(classifier): incremental scan — status, top-levels-first, per-folder state
Replaces the full depth-first "scan everything, then render once + expandAll +
selectAll" walk (which looked stalled and was a render bomb on a large network
drive) with a progressive, breadth-first scan:

- Walks level-by-level behind a bounded worker pool (6), rendering as it goes —
  the top folder levels appear immediately, deeper levels fill in the
  background. Workers await between directories so the UI stays responsive.
- Live status line under the tree header: "Scanning… N folders · M files —
  <current path>", ending "Scanned … in Ts."
- Per-folder state machine (pending → scanning → children → done) with
  immediate subfolder/file counts; the row is greyed (with a faint pulse) until
  its whole subtree is scanned, then turns solid — the at-a-glance signal.
- Opening a folder jumps its subtree to the front of the scan (ensureScanned),
  so an opened folder always shows complete contents; idempotent vs the
  background walk.
- No more auto-expand/auto-select-all (that loaded the entire drive up front);
  the root is selected so the grid shows its files immediately.
- ZIPs stay expandable, scanned inline into virtual nodes (already in memory
  once read); whole zip subtree marked done at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:03:46 -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
86d667309d fix(classifier): drop vestigial Save-All delays that masked a fixed await bug
saveAllFiles() carried a 200ms inter-operation sleep "to prevent race
conditions" plus a 300ms post-error "settle" sleep. But saveFile() is fully
awaited and each iteration renames a distinct file, so the saves are already
serialized — the sleeps were the band-aid for an earlier missing-await bug
(the "ensure properly awaited" comment marks where that got fixed). Remove
both: correctness comes from the awaits, the operations are independent, and
Save All no longer pays 200ms per file (≈10s on a 50-file batch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:07:28 -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
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
050902fa9e chore: elevation slot in every tool + docs + helper file splits + smell cleanup
Polish pass after the big refactor in 2d114fc.

== Header elevation slot propagated ==

shared/elevation.{js,css} surface a header checkbox for admins.
30-minute sudo-style cookie window (Max-Age=1800, SameSite=Lax).
Only renders when /.profile/access reports can_elevate=true; quiet
for non-admins. Slot added to all 7 tool templates and concat'd
into all 7 build.sh files; admin in any tool now sees the toggle.

Three text-rename ride-alongs in archive/classifier/transmittal
templates: "Add Local Directory" → "Use Local Directory" (the same
rename that landed in browse earlier in this branch).

== Docs ==

- CLAUDE.md gets an "Admin elevation is sudo-style" paragraph in
  the "Things that bite if you forget" section.
- AGENTS.md gets a dedicated "Admin elevation (sudo-style)" section
  alongside "Bearer tokens" — same depth as the existing auth docs.

== Helper file splits ==

The retired form editor's shared helpers got bundled into a single
zddc_admin.go in the cleanup; that name is now misleading. Split by
concern:

- admin_helpers.go: hasAnyAdminScope (the only admin-specific helper)
- paths.go: resolvePath, urlPathOf, chainDirs (URL ↔ filesystem path
  math — used by several profile / zddc-file handlers)
- profile_assets.go (renamed from zddc_admin_assets.go): custom CSS
  pipeline. URL renamed from /.profile/zddc/assets/ → /.profile/assets/
  since /.profile/zddc/ no longer hosts an editor.
- treeEntry moves to profilehandler.go (alongside AccessView, its
  only consumer).
- writeError moves to profileprojects.go (its only consumer).

== Smell cleanup ==

- zddc.HasAnyAdminGrant(fsRoot, email) — new elevation-independent
  primitive that walks the cascade and reports whether email is named
  in any admin: list anywhere. Replaces the synthetic-elevated probe
  hack in enumerateAccess (`Principal{Email, Elevated: true}` was
  "lying" to the elevation gate to ask what it would say). The handler's
  hasAnyAdminScope collapses to a 4-line wrapper that gates on
  p.Elevated and delegates.
- Access-log middleware records `elevated` per request, so forensics
  can distinguish "admin acting as user" from "admin exercising power."
- browse/js/app.js's ?file= deep link walks multi-segment paths. Each
  intermediate segment is matched + expanded; the leaf gets
  selected/previewed. Auto-shows hidden when any segment starts with
  . or _. Silently no-ops on unresolved segments.

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