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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>