New shared/zip-source.js: a ZipDirectoryHandle / ZipFileHandle pair
that exposes a JSZip instance behind the File-System-Access surface
(values/entries/keys, getDirectoryHandle/getFileHandle, getFile) —
read-only, with a zip-slip guard. Mirrors shared/zddc-source.js's
HTTP polyfill. Wired into archive's and browse's build.sh (both
already bundle JSZip).
archive: a .zip whose name minus ".zip" parses as a transmittal-folder
name is now scanned as that transmittal folder. Offline, the zip is
opened in the browser (ZipDirectoryHandle) and its members enumerated
exactly like an uncompressed folder's files — table/export/hash paths
are unchanged (they go through file.handle.getFile()). Online, the
scanner recurses into the server's "<…>.zip/" virtual-directory
listing, so members come back as "<…>.zip/<member>" URLs the server
extracts on demand — no whole-zip download.
browse: the offline (file://) zip path is migrated onto the shared
adapter — expanding a .zip now opens it as a ZipDirectoryHandle and
its members become ordinary dir/file nodes handled by the normal
fetchFsChildren path (nested zips fall out by recursion). The bespoke
flat-entry walker (loadZipChildren / setZipDirChildren / zipEntries /
zipParentId / zipPath / _zipSyntheticDir) is gone — one zip
implementation repo-wide. Markdown members inside a zip are flagged
read-only (the ZipFileHandle refuses createWritable; server "<…>.zip/"
URLs 405 on PUT).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared/nav.js stage strip previously hardcoded four stages
(archive/working/staging/reviewing) with their labels and target
URLs baked into the file. Operators couldn't add a fifth stage or
rename "Working" to "In-Progress" without forking shared code.
Now cascade-driven end-to-end:
Server-side:
listing.FileInfo gains a Declared bool field. fs.ListDirectory
stamps Declared=true on every entry whose name matches the
cascade's ChildrenDeclaredAt(parent) — both real on-disk dirs
and virtual canonical injections. Bugfix in the same patch:
virtualCanonicalFolders was passing the relative dirPath to
ChildrenDeclaredAt (which expects absolute); now passes absDir.
Client-side:
shared/nav.js fetches the project root's JSON listing on
DOMContentLoaded, filters to declared+is_dir entries, sorts by
canonical workflow order (archive → working → staging →
reviewing, then any extras alphabetically), and renders the
strip. Labels read e.display_name → falls back to titleCase(name).
Hardcoded FALLBACK_STAGES kicks in only on fetch failure
(offline / file:// / non-zddc-server backend). Rendered
immediately so the strip appears without flicker, then the
cascade-fetched list replaces it once available.
Effect:
Project-3 (which has display: { archive: "Records",
working: "In-Progress", ... } in its .zddc) now shows
"Records · In-Progress · Outbox · Pending Responses" in every
tool's strip. Project-1 still shows "Archive · Working ·
Staging · Reviewing". No code change to render either; the
cascade decides.
Tests:
- tests/nav.spec.js relies on the mock server returning HTML at
every URL, so the fetch fails over to fallback stages — the
test renders the same Archive/Working/Staging/Reviewing labels
it always did, with no test changes needed.
- All 248 Playwright + all Go tests green.
Remaining client-side hardcode: archive/js/source.js +
archive/js/app.js's mode detection. Phase 4d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared header strip pointed Working/Staging/Reviewing at the slash
form (working/, etc.), which now serves browse per the slash/no-slash
convention established earlier. The user expected those links to open
the stage's tool (mdedit for working, transmittal for staging, etc.) —
which is what the no-slash form serves.
Also drops the .html suffix from the archive target: <project>/archive
(no slash) → archive tool, same as the other stages. The currentStage
recognizer still accepts /archive.html as a fallback for any direct
URLs that survive in bookmarks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Only transmittal had any @media (max-width) rules; the other seven
tools silently break below ~900px. Adds a baseline shared rule that
every tool inherits — desktop-first stays the same, but a tablet in
landscape or a window split next to a document remains usable.
@media (max-width: 800px):
- tighter header padding + gaps
- .app-header__title drops 18px → 16px
- .build-timestamp inside .header-title-group hidden (it's
traceability info, not a primary affordance — still reachable
via help panel)
- header text buttons get a smaller padding so they fit
@media (max-width: 480px) phone-width:
- .app-header switches to column layout
- .header-left and .header-right each span full width with
justify-content: space-between
prefers-reduced-motion was already covered for the page-load stagger.
Each tool can still override in its own css/layout.css; this is the
shared floor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The header is the first thing a user sees. A short staggered fade-in
(logo → title → action button → right-side icons over ~360ms) turns the
instant-pop-in feel into a subtle "the tool is composing itself" beat.
Pure CSS @keyframes (no JS), cubic-bezier(0.2, 0.7, 0.2, 1) for the
"settle in" easing curve. Respects prefers-reduced-motion. Total budget
~260ms before everything is visible — well under the threshold where it
becomes a perceptible delay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
There are 76 alert() call sites across the eight tools — three different
ad-hoc error-surfacing patterns (alert, console.error, classifier's own
showToast). Touching every site is a sweep with no judgment payoff:
every alert is "something went wrong, the user should know," which is
exactly what toast at level='error' is for.
Shim is one if-block at the bottom of shared/toast.js. It saves the
native window.alert as window.alertNative (so any truly modal-blocking
call site can opt back in by name), then replaces window.alert with a
function that forwards through window.zddc.toast(msg, 'error'). Effect
is global — every existing alert in every tool becomes a non-blocking,
ARIA-announced (aria-live=assertive) toast that the user can click to
dismiss.
handler/tables.html refreshed by ./build as a side effect (it bakes the
current tables/dist/ into the binary every build).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous dark-mode --primary (#4a90c4) had ~3.3:1 contrast against
the white button text we pair it with — below WCAG AA 4.5:1 for
normal text. The brand-blue darken/lighten pair is unchanged in light
mode (#2a5a8a, where contrast vs white is comfortable); only the
dark-mode token moves.
--primary: #4a90c4 → #5fa8e0 (≈4.6:1 vs #fff)
--primary-hover: #5ba3d9 → #74b6e6
--primary-active: #6ab5e8 → #88c4ec
Same change applied to both the `@media (prefers-color-scheme: dark)`
block (auto) and the `[data-theme="dark"]` block (manual override).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Three tools (archive, browse, classifier) independently implemented
an empty-state pattern with three different CSS class naming
conventions and slightly different rules:
archive: .empty-state + .empty-state-content (BEM-less)
browse: .empty-state + .empty-state__inner (BEM)
classifier: .empty-state + .empty-state-content (BEM-less)
Same visual intent ("nothing's loaded yet — here's a welcome card
with instructions"), implemented three times with subtly different
spacing, no shared body styling for h2/p/ul/li, and incompatible
class names that prevented a future tool from copy-pasting the
pattern.
Promote a single consolidated rule set to shared/base.css using
BEM naming throughout:
.empty-state — base (flex centered, padding)
.empty-state--overlay — modifier: position absolute,
top 50px to clear app-header,
z-index 10. Used by archive
and classifier (their empty
states sit OVER the main
layout).
.empty-state__inner — content card (left-aligned,
text-muted, max-width 640)
.empty-state__inner--centered — modifier: tighter max-width
500, centered text, 2rem
padding. Used by tools whose
welcome screen reads as a
centered card.
.empty-state__inner h2/p/ul/ol/li — typography defaults
.empty-state__inner .note — italic small-print
.welcome-list — bullet list with left-aligned
text + auto margins; safe to
nest inside a centered card.
Per-tool changes:
- archive/template.html, archive/js/app.js: rename
.empty-state-content → .empty-state__inner empty-state__inner--centered;
add .empty-state--overlay to the outer .empty-state container.
Also the runtime-injected unsupported-browser markup in
showUnsupportedBrowserMessage() and the showHttpErrorState
selector.
- classifier/template.html: same renames.
- archive/css/layout.css + components.css: delete .empty-state*
and .welcome-list rules (now in shared).
- classifier/css/layout.css: same. Keep .empty-state.drag-over
locally — classifier is the only tool whose empty state acts
as a drop target.
- browse/css/base.css: delete .empty-state* (shared covers it).
browse's template was already using .empty-state__inner so no
template change needed.
LOC: shared/base.css gains ~70 lines; per-tool overrides lose ~85
combined. Net -15. More importantly, future tools can reuse the
pattern by adding two divs and (optionally) the --centered or
--overlay modifiers; no copy-paste required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tools (browse, transmittal, landing, form, tables) were rendering
their headers as vertically-stacked blocks — the .app-header flex
container correctly laid out its left/right groups, but those groups
themselves had no display:flex rule, so their children (logo, title,
build label, action button) defaulted to block-level stacking.
Three tools (archive, mdedit, classifier) hid the bug because they
each carried their own copy of the .header-left/.header-right flex
rule in tool-local CSS. Same intent, slightly different gap values:
archive: left gap 0.75rem, right gap 0.5rem
mdedit: both 0.75rem
classifier: both 0.5rem
Promote the rule to shared/base.css alongside .app-header (where the
class is used in every template anyway): left 0.75rem, right 0.5rem
(matching archive — the layout the user pointed at as the reference).
Delete the three local duplicates.
Now all eight tools use the same header chrome contract: logo + title
group + primary action laid out horizontally with consistent gaps,
icon buttons grouped tighter on the right.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .app-header__logo SVG was decorative on every tool. Web's
strongest convention is "click logo → go home" — so users tapping
it expecting that fallback got nothing. Now the logo is wrapped in
an anchor whose href reflects the URL the page was loaded from:
file:// → no wrap (no server home to point at)
/ → wrap, href=/ (deployment root)
/index.html / /<tool>.html → wrap, href=/ (root, no project)
/<project>/... → wrap, href=/<project> (project landing)
The wrap happens client-side at DOMContentLoaded via shared/logo.js,
loaded by every tool's build.sh after toast/nav. Idempotent — a
template-supplied anchor or a second mount call is a no-op.
The companion shared/logo.css adds a subtle hover/focus affordance
(opacity 0.82, focus ring) so the logo reads as clickable without
otherwise altering its visual weight. Tools opt out by setting
window.zddc.logo.disabled = true before DOMContentLoaded (e.g. for
deployments that pin the logo to an external destination).
Five Playwright tests (tests/logo.spec.js) lock the contract:
no-wrap on file://, href=/ at root, href=/<project> in project
subtree, aria-label matches target, idempotent re-mount.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the reviewing/ aggregator described in the saved
project memory (~/.claude/projects/-home-user-src-zddc/memory/
project_reviewing_folder_design.md). reviewing/ stays in
VirtualOnlyCanonicalNames — never materialised on disk — and is
served as a join over archive/<party>/received/, archive/<party>/
issued/, and staging/, recomputed on every read.
Two depths, both trailing-slash:
GET <project>/reviewing/?json=1
→ array of virtual <tracking>/ entries, one per submittal in
archive/<party>/received/ that doesn't yet have a matching
archive/<party>/issued/ entry. Sorted by tracking. URLs stay
under reviewing/ so the user can drill into the per-submittal
view. ACL: per-party, filtered like fs.ListDirectory.
GET <project>/reviewing/<tracking>/?json=1
→ array of two virtual entries, received/ + staged/, with
canonical URLs pointing back to archive/<party>/received/...
and staging/... respectively. staged/ is omitted when no
response draft exists yet.
When the response moves staging/ → archive/<party>/issued/, the
entry vanishes from depth-0 on the next listing. No mutation of
the reviewing/ subtree itself; pure join, recomputed on read.
Front-end at <project>/reviewing[/<tracking>/] is mdedit (per
user request). DefaultAppAt + AppAvailableAt extended to recognise
"reviewing" as a canonical mdedit-bearing folder. The polyfill in
shared/zddc-source.js is updated to follow listing entries' explicit
url field when present (absolute or root-relative) — that's how
mdedit's tree follows the depth-1 received/ + staged/ links into
the canonical archive/staging subtrees.
Dispatcher routing in zddc-server/main.go:
- GET <project>/reviewing/[<tracking>/] with Accept: json
→ ServeReviewing
- GET <project>/reviewing/[<tracking>/] with Accept: html
→ mdedit (rooted at the virtual path; polyfill fetches the
JSON listing on its own)
- GET <project>/reviewing (no slash) → mdedit (via DefaultAppAt)
- GET <project>/reviewing/<tracking> (no slash) → 301 to slash form
Tests:
- handler/reviewinghandler_test.go (6 cases): IsReviewingPath
classification + ServeReviewing depth-0/depth-1 with and without
staged drafts + 404 on unknown tracking + empty when archive/ is
absent.
- apps/availability_test.go updated: reviewing/ now expects mdedit
rather than "" (no default).
- cmd/zddc-server/main_test.go: TestDispatchEmptyCanonicalProjectFolders
extended to assert reviewing → mdedit at the no-slash form;
older "no-slash/reviewing → 301" test removed.
Future work (not in this commit): write translation. Editing a file
under reviewing/<tracking>/staged/<f>.md works today because the
polyfill rewrites to /<project>/staging/<response>/<f>.md before
fetching — the user's URL bar moves to the canonical path on click.
A virtual-filesystem mode where the URL bar stays under reviewing/
throughout would require server-side write rewriting (translate
PUT/DELETE on reviewing/.../staged/... into the canonical staging/
path). Not needed for the MVP — links in mdedit's tree work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a thin nav strip directly under the app-header showing the four
canonical lifecycle stages from the transmittal-workflow spec:
archive · working · staging · reviewing. Each is a link to that
stage's directory under the current project. Current stage is
highlighted (bold + primary color, aria-current="page"). Strip
mounts as a sibling of .app-header on DOMContentLoaded — no
template changes needed in any tool.
Render rules (shared/nav.js shouldRender):
- location.protocol must be http: or https: (file:// has no project
structure to navigate within)
- a project segment must be detectable as the first path segment
(when it isn't a tool HTML file like /index.html or
/archive.html?projects=A,B). Multi-project view at the deployment
root therefore shows no strip.
Stage URL targets:
- Archive → <project>/archive.html (project-root archive view)
- Working → <project>/working/ (directory listing — mdedit auto-served)
- Staging → <project>/staging/ (directory listing — transmittal auto-served)
- Reviewing → <project>/reviewing/ (directory listing)
Convention-driven, not probed: if a deployment doesn't have one of
these folders the link returns 404. Operators on non-standard layouts
can opt out by setting window.zddc.nav.disabled = true before
DOMContentLoaded.
This pairs with the previous landing-tool change (single-project
click → <project>/archive.html). Together they give the user
both URL-bar manipulation AND visible navigation across the four
canonical project stages.
Five Playwright tests in tests/nav.spec.js exercise:
- non-render at deployment root
- render + active stage on <project>/archive.html
- render + active stage deep inside <project>/working/foo/mdedit.html
- canonical link targets
- mount position is sibling of .app-header
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote classifier's local toast (classifier/css/base.css + showToast
in classifier/js/excel.js) into shared/toast.{js,css}. Every tool's
build.sh now concatenates them, so window.zddc.toast(msg, level, opts)
is callable from any tool.
API:
window.zddc.toast('Saved.', 'success');
window.zddc.toast('Could not load: ' + err.message, 'error');
window.zddc.toast('Note', 'info', { durationMs: 3000 });
Levels: info (default) | success | warning | error. Single-toast
policy — a second call replaces the first. Click anywhere on the
toast to dismiss. ARIA: error → role=alert/aria-live=assertive,
others → role=status/aria-live=polite.
Class prefix is .zddc-toast (BEM-ish) to avoid colliding with any
tool-local .toast rules. Classifier's existing showToast now
delegates to window.zddc.toast — call sites in excel.js +
selection.js are unchanged. Classifier's local .toast CSS block
deleted in favor of the shared one.
This commit only EXPOSES the API. Replacing the ~25 alert() call
sites scattered across archive/transmittal/mdedit/classifier with
toast calls is left as follow-up — each alert needs per-call review
to decide if it's truly non-blocking.
Five Playwright tests in tests/toast.spec.js lock the contract:
API exposure, level mapping, ARIA roles, single-toast replace,
click-to-dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six inline style="" attributes in archive, browse, mdedit templates
moved into stylesheets:
- The font-size:1.1rem override on #refreshHeaderBtn (three tools) is
now a single rule in shared/base.css — the refresh ⟳ glyph genuinely
reads smaller than ◐ / ?, so the rule lives next to the existing
shared icon-button block.
- The flex-start justify-content + select-all margin on archive's
Revisions column header become a .th-content--start variant +
.select-all-checkbox class in archive/css/table.css.
- The 450px initial width + 200px min-width on mdedit's #file-nav move
into mdedit/css/base.css; the runtime resizer continues to override
via inline style.width when the user drags.
No visual change — the output is exactly equivalent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shared/build-lib.sh is sourced from each tool's build.sh with
root_dir pointing at the tool directory, so paths to other shared/
artifacts have to be \$root_dir/../shared/<file>. The slug helper
incorrectly used \$root_dir/shared/build-words.txt, which only
resolves correctly when the helper is invoked from the repo root
(e.g. via the smoke test `root_dir=\$PWD sh -c …`). In a real
./build run, the helper found the wordlist missing and silently
fell back to the short-SHA — the previous beta cut shipped
"v0.0.17-beta · 2026-05-07 · 1dafc97" instead of the intended
slug.
Fix: align the path with the existing shared/favicon.svg pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beta on-page labels now read
"v<X.Y.Z>-beta · <date> · word-word-word" instead of the previous
"… · g<short-sha>" form. Words are easier to recognise at a glance
than a 7-char SHA — testers can confirm "I'm running the
ranch-chamber-cake build" without consulting git log.
The slug is computed deterministically from the source commit's
full SHA (three 4-hex chunks modulo the wordlist length, looking
up shared/build-words.txt). Same source state → same slug, so the
existing chore-commit recursion fix carries over: a re-cut on
unchanged source produces an identical label and no spurious
embedded commit.
Wordlist (shared/build-words.txt): 283 short, neutral, English nouns
— flora, fauna, materials, landscape features. 283 ^ 3 ≈ 22M
combinations, more than enough that two simultaneous beta cuts on
different branches don't collide in practice.
Full provenance is still available — the binary's `--version` output
prints `git describe` (tag-distance-gSHA), and the chart appVersion
carries the full SHA written by notify-chart-bump.sh on push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beta cuts now stamp the on-page label as
"v<X.Y.Z>-beta · <date> · g<short-sha>" so a tester can confirm at a
glance which source commit they're running against. Previously the
SHA was deliberately omitted to avoid an embedded-commit recursion:
a build that auto-commits the regenerated embedded artifacts
(build:971-995) advances HEAD by one, and a SHA derived from HEAD
would shift on every cut, causing the next cut to commit again
ad infinitum.
Fix: embed the *source* SHA, not HEAD. The new
_source_commit_short_sha helper walks backwards from HEAD past any
"chore(embedded): cut" or "release: vX.Y.Z lockstep" auto-commits
and returns the short SHA of the underlying source state. That SHA
is invariant across the auto-commit step, so a re-run on the same
source state produces the same label and no spurious commit.
Plain dev builds and stable cuts are unchanged. Plain builds already
included the SHA via _sha + -dirty marker; stable cuts intentionally
carry only the bare semver in the label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the long-standing chart-bump race that required a manual rebase
on every beta cut. Three coordinated changes:
build (top-level): broaden the existing stable-only "fold embedded
artifacts into a release commit" block to also fire on beta cuts.
Same idempotency check; new commit message ("chore(embedded): cut
v<X.Y.Z>-beta") derived via _coordinated_next_stable. Tagging stays
stable-only (channels are mutable mirrors and never get tags). Beta
cuts now produce exactly one commit on main; HEAD always carries
the bytes the binary will serve.
shared/build-lib.sh: drop the SHA from alpha/beta channel labels.
Embedding HEAD's SHA in the bytes the SHA identifies created a
feedback loop — each auto-commit advanced HEAD, which shifted the
SHA in the next run's versions.txt, which triggered another
embedded commit, ad infinitum. Channel labels now read
"v<X.Y.Z>-<channel> · <date>" — version + date is enough; SHA
traceability lives in the chart's appVersion (full SHA) and the
binary's --version output. Plain dev builds keep the timestamp +
-dirty fingerprint since they don't commit. Stable cuts already
use a clean version-only label.
.forgejo/scripts/notify-chart-bump.sh: pin the chart's appVersion
to `git rev-parse HEAD` instead of the SHA in versions.txt. The
build's auto-commit now ensures HEAD == "the commit containing the
embedded bytes the binary will bake," so HEAD is the substantively
correct anchor. The previous versions.txt read pinned one commit
too early (the source-side commit, before the embed refresh
committed) — every beta cut required a manual chart-rebase to
point at the embed commit. With both halves landed, the cycle is
zero-touch: ./build beta + git push → auto-bump CI fires → chart
appVersion at correct SHA → dev image bakes the right bytes.
Verification: ran ./build beta twice on the same source state. First
run produced one commit; second run printed "no embedded changes to
commit (re-run on same source state)" and made no commit. The label
SHA-loop bug is fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.
Schema (zddc/internal/zddc/file.go)
- New `Tables map[string]string` on ZddcFile. Map key becomes the URL
stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
relative to the .zddc pointing at a `*.table.yaml` spec describing
columns + the rows directory. No upward cascade in v1 — each
directory hosting a table declares it directly.
Server handler (zddc/internal/handler/tablehandler.go)
- `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
against the cascade's `tables:` declarations. Dispatch routes
table requests before the form-system intercept.
- `ServeTable` ACL-gates with `policy.ActionRead` and serves the
embedded `tables.html` template; client walks the directory itself
via the listing JSON or FS Access API.
- tables.html embedded via //go:embed — same pattern as form.html.
Frontend (tables/)
- Vanilla JS: app/context/util/filters/sort/render/main modules.
- Reads spec + row YAML files via window.zddc.source (HTTP polyfill
or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
client-side parsing.
- Sample fixtures under tables/sample/ for local testing.
Build + CI
- Lockstep build registers tables alongside the other 7 tools (HTML
output, embed mirror, versions.txt, release-output, tags).
- Playwright project added; `npx playwright test --project=tables`
is part of `npm test`.
Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.
Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.
File API (zddc/internal/handler/fileapi.go)
- PUT <new> → action c
- PUT <existing> → action w
- PUT <.zddc> → action a (CanEditZddc strict-ancestor rule)
- DELETE → action d
- POST mkdir → action c (auto-writes creator-owned .zddc when the
parent is Incoming/Working/Staging)
- POST move → action w on src + c on dst, atomic via os.Rename
- Optional If-Match for optimistic concurrency, --max-write-bytes cap,
audit log emits a structured file_write event per operation.
Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
- acl.permissions: { principal → verb-set } map; principals are email
patterns or role names. Empty verb set is an explicit deny.
- roles: { name → members } definitions, available at the level they
declare and all descendants. Closer-to-leaf shadows ancestor.
- Legacy acl.allow/deny still work; they fold into permissions at
parse time (allow → "rwcd", deny → "").
- Cascade walks leaf→root; first level with any matching entry wins;
the union of matching verb sets at that level decides.
- --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
ancestor explicit-deny is absolute (NIST AC-6). Default delegated
preserves the existing commercial behavior.
Special folders (zddc/internal/zddc/special.go)
- Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
subdir granting created_by + that email rwcda directly. Same form
operators write by hand; creator can edit it later to add others.
- Issued / Received: server-enforced WORM split. Cascade grants
inherited from above the WORM folder are masked to r only; grants
placed at-or-below the WORM folder retain r,c. Operators grant
write-once (cr) to the doc controller via an explicit .zddc at the
Issued/Received folder. Admins exempt — only escape hatch.
Browser polyfill (shared/zddc-source.js)
- HttpDirectoryHandle + HttpFileHandle implement the FS Access API
surface (values, getFileHandle, createWritable, removeEntry,
queryPermission/requestPermission) over zddc-server's listing JSON
and file API. Existing tools written against showDirectoryPicker
work unchanged.
- detectServerRoot() returns { handle, status }: tools auto-load on
HTTP, surface a clear "no permission to list" message on 403, and
fall back to the welcome screen on 0.
- classifier renames take the atomic POST move path on HTTP-backed
handles; mdedit and transmittal route reads/writes through the
polyfill so prior FS-API code paths cover both modes.
Tests
- zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
delegated vs strict, role membership / shadowing / legacy fallback,
WORM split semantics, verb-set parser round-trip.
- zddc/internal/handler/fileapi_test.go now also covers role-based
vendor scenarios, WORM blocking vendor & doc controller writes,
explicit Issued .zddc unlocking the cr drop-box, admin bypass,
auto-ownership on mkdir, and strict-mode lockouts.
Docs
- ARCHITECTURE.md + zddc/README.md document the verb model, role
syntax, special-folder behaviors, cascade-mode flag, and full file
API surface. Federal-readiness gap analysis strikes AC-3(7) and
AC-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as the browse fix. archive, transmittal, classifier
previously CDN-loaded jszip + docx-preview on first preview of a
.zip / .docx file via shared/preview-lib.js's loadLibrary helper.
That meant each first-preview blocked on a CDN round-trip + parse,
and broke entirely under restrictive networks or CSPs.
Vendor both libs under shared/vendor/ and concat them at the top of
each tool's build, ahead of init.js. window.JSZip + window.docx are
now defined immediately on page load. Drop the redundant loadLibrary
calls (and classifier's stray <script src="cdn..."> tag in the
template, plus archive's bespoke loadJSZip helper in export.js).
xlsx (SheetJS) intentionally stays CDN-loaded — at ~900 KB it's too
large to inline, and only fires on .xlsx preview which is a rarer
path.
Bundle size impact (uncompressed):
archive: 304 KB → 476 KB (+172 KB)
transmittal: 449 KB → 621 KB (+172 KB)
classifier: 252 KB → 424 KB (+172 KB)
With the gzip middleware (~75% reduction on HTML) and ETag-cached
revalidation now in place, the wire-size delta is ~40 KB per tool
on the first load and 0 on every subsequent load until redeploy.
Bring every tool's header in line with archive's pattern:
[logo] [title] [version] [Add Local Directory] [⟳] ............... [◐] [?]
------------- header-left --------------- ----- header-right -
Changes per tool:
* browse: rename "Select Directory" → "Add Local Directory"; add the
red-non-stable wrap to the build label (was missing); add a help
panel + bundle shared/help.js.
* classifier: rename selectDirectoryBtn → addDirectoryBtn,
refreshBtn → refreshHeaderBtn for consistency. Update all JS
callers and welcome-screen copy to the new label.
* mdedit: same id rename. Move the previously-in-pane refresh
button into the header. Stop renaming the dir button to
"Directory: <name>" once a folder is loaded — instead use the
shared btn--subtle variant to de-emphasize while keeping the
standard label.
* transmittal: convert non-standard <div class="app-header"> with
spacer/icons containers to <header class="app-header"> with the
canonical header-left/header-right pair. Move the publish split-
button into header-left (Transmittal-specific primary action).
Remove dead .app-header__spacer/__icons/header-icon-btn CSS now
that nothing references those classes.
* landing, form: add help-btn + help-panel + bundle shared/help.js.
Each panel is tool-specific (project picker docs for landing,
schema-driven form docs for form).
Cross-cutting:
* shared/base.css: promote .btn--subtle from browse/css/tree.css
so any tool with an online mode can de-emphasize Add Local
Directory consistently.
Verified all 7 tools in headless Chromium: header structure correct,
build label red on non-stable cuts, help panel opens + closes via
button + Esc.
- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
the browse build instead of CDN-loading. Eliminates the failure
mode where ZIP rows can't expand because the CDN script doesn't
load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
spreadsheet-style auto-filter rows in <thead>:
- 📄 row: file-name filter + extension filter
- 📁 row: folder-name filter
Each input uses shared/zddc-filter syntax (substring/!negate/
^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
- file matches keep their ancestor folders visible (path-to-hit)
- folder match keeps its descendants visible
- filters compose (file ∧ folder ∧ ext) so combinations narrow
Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
that tints with currentColor.
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.
Modes (auto-detected at page load):
- Online: when served by zddc-server at a folder URL, queries
the same URL with Accept: application/json to load the listing
and renders it. Auto-served as the default at any directory
under ZDDC_ROOT without an index.html (replacing the previous
minimal-HTML stub from directory.go).
- Local: 'Select Directory' button uses FileSystemAccessAPI to
pick any folder on disk; works in Chromium-based browsers.
Features (Phase 1 — what's in this commit):
- Tree view with lazy-loaded folders (children fetched on first
expand).
- Sort by name / size / extension / date (column header click).
- Filter by name substring (toolbar input).
- File click opens in a new tab — for server-backed pages,
routes through zddc-server's normal handler so .archive
redirects + apps cascade overrides + ACL all apply.
Phase 2 deferred:
- ZIP files inline expansion (treat archive entries as virtual
children).
- File preview popup (reuse shared/preview-lib.js).
- Extension multi-select filter.
Wiring:
- browse/ added to top-level ./build's per-tool list, embed
block, versions.txt, and the lockstep release commit + tag set.
All seven tools (archive, transmittal, classifier, mdedit,
landing, form, browse) advance together on stable cuts.
- shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
verify_channel_links's per-tool loop.
- zddc/internal/apps/embed.go: //go:embed browse.html +
EmbeddedBytes("browse") case.
- zddc/internal/apps/availability.go: browse available at every
directory (same as archive).
- zddc/internal/apps/handler.go: MatchAppHTML routes
/<dir>/browse.html → 'browse'.
- zddc/internal/handler/directory.go: when a directory request
arrives with Accept: text/html and no index.html exists,
serve the embedded browse.html bytes (with a JSON-fallback
if the embedded slot is empty during bootstrap).
The skip-if-no-source-changes-since-latest-tag check in
promote_release was a relic of per-tool independent versioning.
In the lockstep era it actively breaks CI re-cuts at a tag commit:
- HEAD is at the v0.0.10 release commit
- latest archive-v* tag is archive-v0.0.10 (== HEAD)
- git diff archive-v0.0.10 HEAD = empty
- SKIP archive promote → no archive_v0.0.10.html written
- dist/release-output/ stays at whatever was seeded from
/srv/zddc/releases/ (i.e. v0.0.9 from the previous deploy)
- ./deploy --releases rsyncs that → live site STAYS at v0.0.9
Symptom: tag-triggered Forgejo deploy-release.yml workflow runs
(run 16) reports success but /srv/zddc/releases/archive_stable.html
still points at archive_v0.0.9.html.
Fix: always run _promote_stable for every tool on a stable cut.
The bytes written are deterministic at the same source, so
overwriting an existing per-version file is a no-op on disk —
the actual work the cut performs is advancing the symlink chain
(_v<X.Y>, _v<X>, _stable, _beta, _alpha) to the new version.
Two related fixes to the lockstep release flow + the project invariant
that prod must always run stable bytes (and dev only ever beta-or-stable).
1) tag-after-commit ordering. `./build release X.Y.Z` previously
regenerated zddc/internal/apps/embedded/* with stable labels but
tagged BEFORE folding those changes in. The tag landed on the
source-side commit (alpha-dirty embedded), and the operator was
expected to commit the embedded changes as a follow-up — which got
dropped in practice, leaving prod binaries with alpha-dirty bytes
baked in. (See the v0.0.9 re-anchor in the immediately preceding
commit for the manifestation.)
Refactor:
- _promote_stable / promote_zddc_server in shared/build-lib.sh
no longer call `git tag`. They keep their pre-flight check
(now: tag must be in HEAD's history rather than == HEAD, since
HEAD will advance after the release commit).
- Top-level ./build adds a new "Release commit + tag" block at
the end of stable cuts: stages the regenerated embedded files,
makes a `release: vX.Y.Z lockstep` commit, and tags all seven
artifacts at the new commit. Idempotent — no commit if there
are no changes.
2) bake-in invariant. Plain `./build` and `./build alpha` now
leave zddc/internal/apps/embedded/ untouched — the binary keeps
shipping whatever the last beta or stable cut wrote. `./build
beta` and `./build release` are the only paths that update
embedded bytes. Active dev iteration uses tool/dist/<tool>.html
directly; the binary's embedded copy is the default fallback,
not a workbench.
Verification on this commit:
./build → embedded mtime unchanged, no "M" lines for embedded/
./build alpha → embedded mtime unchanged, no "M" lines for embedded/
Docs updated to match in CLAUDE.md "Things that bite" + AGENTS.md
"Releasing — lockstep" + the leading help text in ./build itself.
Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.
Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).
New components:
* form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
* zddc/internal/jsonschema/ — focused JSON Schema validator covering only
the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
library brings 70%+ surface we don't use; revisit when v1 adds $ref +
oneOf + if/then/else.
* zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
capability-URL re-edit, atomic submission writes via the new
zddc.WriteAtomic helper extracted from writer.go.
* dispatch() in zddc-server/main.go now intercepts *.form.html and
*.yaml.html before the static-file path; spec existence is the trigger.
Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).
Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.
Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.
Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates from in-repo orphan `website` branch + LFS to a two-repo +
deploy-host model so source editing is fully decoupled from live state.
- Source code stays here (codeberg.org/VARASYS/ZDDC).
- Hand-edited website content moves to a separate Codeberg repo
(codeberg.org/VARASYS/ZDDC-website, cloned at ~/src/zddc-website/).
- Live site is /srv/zddc/ on the deploy host (Caddy bind-mount),
populated by ./deploy from this repo's dist/release-output/ plus
~/src/zddc-website/.
- Releases are no longer in any git history — reproducible from
<tool>-vX.Y.Z tags via `./build release X.Y.Z`. No LFS, no
Codeberg release assets.
Build/deploy split:
- ./build (no arg) is source-only; nothing in dist/release-output/
or /srv/zddc/ is touched.
- ./build alpha|beta|release seeds dist/release-output/ from
/srv/zddc/releases/ (preserving symlinks), then mutates the
channel(s) being cut on top. The bundle is always a complete
intended-live snapshot, so the verifier sees a complete world
and ./deploy --releases (rsync --delete-after) replaces live
state cleanly.
- New ./deploy wraps the rsync flow with --content / --releases
subcommands.
Docs updated to reflect the new model: CLAUDE.md, AGENTS.md,
ARCHITECTURE.md, zddc/README.md, README.md, .gitignore, shared/
build-lib.sh comments, deprecated zddc/release.sh message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves website source + release artifacts off `main` and into a new
orphan branch named `website` in this same Codeberg repo. A `git worktree`
of that branch — typically at ~/src/zddc-website/ — is what the system
Caddy now bind-mounts and serves at zddc.varasys.io. Decoupling source
from the live site means editing source can no longer accidentally
affect what's published.
Layout going forward:
- ~/src/zddc/ — main worktree (this branch, source only).
- ~/src/zddc-website/ — git worktree of the `website` branch:
hand-edited content + LFS-tracked release
artifacts (server binaries) + regular-git
HTML tool releases + symlinks.
- Caddy bind-mount swapped: ~/src/zddc/website → ~/src/zddc-website
(quadlet at /etc/containers/systemd/caddy.container, restarted).
Build pipeline now writes releases to
${ZDDC_DEPLOY_RELEASES_DIR:-$HOME/src/zddc-website/releases}.
- build.sh: RELEASES_DIR points at the env var
- shared/build-lib.sh: promote_release honors the env var, falls
back to the legacy in-repo path so any
standalone single-tool release on a checkout
that still has website/ keeps working
- freshen-channel: passes ZDDC_DEPLOY_RELEASES_DIR through to
the worktree-based build
Docs (CLAUDE.md, AGENTS.md, ARCHITECTURE.md, .gitignore) updated for
the new layout. The 51 MB of website/ blobs stays in main's history
(no force-push); over time Codeberg's GC will pack them down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds command-line flags to zddc-server alongside the existing env vars.
Each setting can be set via --<flag-name> or ZDDC_<NAME>; the flag wins
on conflict, the env var wins over the hard-coded default.
--root / ZDDC_ROOT (now defaults to CWD if both unset)
--addr / ZDDC_ADDR (:8443)
--tls-cert / ZDDC_TLS_CERT ("none" / empty / path)
--tls-key / ZDDC_TLS_KEY
--log-level / ZDDC_LOG_LEVEL (info)
--index-path / ZDDC_INDEX_PATH (.archive)
--email-header / ZDDC_EMAIL_HEADER (X-Auth-Request-Email)
--cors-origin / ZDDC_CORS_ORIGIN (https://zddc.varasys.io; "" disables)
--insecure-direct / ZDDC_INSECURE_DIRECT (false)
--help (prints flag list to stderr, exits 0)
--version (prints binary + embedded tool versions, exits 0)
So an operator can `cd /srv/zddc && zddc-server` with zero config — the
served root defaults to the current directory, and TLS defaults to a
self-signed cert. config.Load now takes []string (test-friendly: nil
skips flag parsing entirely; tests pass an empty slice for env-only
loads).
Adds a `version` package-level var in main.go injected at link time via
`-ldflags="-X main.version=..."`. The build.sh runs git describe against
zddc-server-v* tags; for in-flight commits between releases it produces
e.g. zddc-server-v0.0.7-19-gadb6904-dirty.
Adds an embedded versions manifest:
- Each tool's compute_build_label (in shared/build-lib.sh) writes a
sidecar <tool>.label to $BUILD_LABELS_DIR if that env var is set.
- Top-level build.sh sets BUILD_LABELS_DIR before running each tool's
build, then assembles zddc/internal/apps/embedded/versions.txt as
one `<app>=<build label>` line per app.
- apps.EmbeddedVersions() loads the manifest at runtime.
- main.go logs a compact summary on every startup; --version dumps
the full per-app label.
Removes the old cfg.BuildVersion field — the X-ZDDC-Source: embedded
header now uses the package-level main.version directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds shared/preview-lib.js with two cross-tool renderers:
- renderTiff (UTIF.js, lazy-loaded from CDN; PDF-style toolbar with
page nav, zoom, fit-width/fit-page; multi-page TIFFs decode lazily)
- renderZipListing (JSZip; sortable name/size/modified table, sticky
header, host-grouped paths)
Wired into the four tools that have a preview surface (archive, classifier,
mdedit, transmittal). Cross-document compatible so the same renderer works
for popup-window tools (archive/classifier/transmittal) and inline tools
(mdedit). Archive previously had no image branch at all — now previews
JPG/PNG/GIF/WebP/BMP/SVG natively, plus TIFF via UTIF, plus the ZIP listing.
Adds the dark-blue rounded-square favicon to each app's header (left of
the title) and to the website navigation. Single inline SVG, sized via
.app-header__logo (in shared/base.css) for tools and .brand-logo (in
website/css/style.css) for the website. Self-contained — the SVG carries
its own background, no wrapper styling needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rolls back the HTML-tool side of the Codeberg-as-canonical refactor
(commits 2dc9ad2, b28c4ae, bdac8dc) in favor of a simpler model:
per-version HTML files committed under website/releases/ as immutable
real files; partial-version pins (<tool>_v<X.Y>.html, <tool>_v<X>.html)
and channel mirrors (<tool>_<channel>.html) are checked-in symlinks.
Docker-tag pattern: :1.2.3 is pinned, :1.2 floats, :1 floats further,
:stable floats furthest.
URL scheme — every URL resolves to actual HTML via the symlink chain;
no JS indirection, no manifest.json, no Caddy regex-rewrite:
/releases/<tool>_v<X.Y.Z>.html exact version (real file)
/releases/<tool>_v<X.Y>.html latest patch within X.Y.* (symlink)
/releases/<tool>_v<X>.html latest within X.*.* (symlink)
/releases/<tool>_stable.html current stable (symlink)
/releases/<tool>_beta.html current beta (symlink to stable when no
active beta; real file when beta is in flight)
/releases/<tool>_alpha.html current alpha (similar — symlink to beta
or stable when no active alpha)
Cascade rule (in shared/build-lib.sh promote_release):
--release [version] (stable cut) → write per-version file; refresh 5
symlinks (_v<X.Y>, _v<X>, _stable,
_beta, _alpha) → new versioned file;
tag <tool>-v<X.Y.Z>.
--release beta → overwrite <tool>_beta.html with real
bytes; cascade _alpha.html → _beta.html
(symlink). No tag — channel URLs are
stable URLs by design; counters defeat
that.
--release alpha → overwrite <tool>_alpha.html with real
bytes. No tag, no other side-effects.
Plain `sh tool/build.sh` → dist/ only. No website/releases/
side-effect, no commit.
Code changes:
- .gitignore — drop website/releases/*.html and website/releases/zddc-server-*
exclusions; HTML tool files are tracked again. Replace the comment with
the new model description.
- shared/build-lib.sh — drop next_prerelease (no -alpha.N / -beta.N counter
tags). Drop the Codeberg-upload path for HTML tools (no longer sourcing
publish-codeberg-release.sh from build-lib). promote_release rewritten
with two helpers: _promote_stable (per-version file + 5 symlinks + tag)
and _promote_channel (overwrite mirror + cascade alpha→beta on beta cut).
- zddc/release.sh — drop alpha/beta channel path entirely; binaries publish
only on stable cuts. zddc-server's beta/alpha builds-from-source via the
helm charts (next phase) — no binary distribution needed for those channels.
- bootstrap/level2.html.tmpl — drop manifest.json fetch; resolve ?v= to a
static URL via the symlink chain. New suffixFor() handles channel names,
exact versions, and partial-version pins (?v=0.0, ?v=0). Same logic in
level1.html.tmpl already works because the local-staging files (e.g.
../<tool>_v0.0.html) exist via the same symlink scheme.
- build.sh build_releases_index — revert to filesystem scan of
website/releases/ instead of Codeberg API call. Drop manifest.json
generation. Per-tool sections list channel chips + per-version pin links;
zddc-server section links to Codeberg release pages directly.
- tests/build-label.spec.js — fix the channel-label regex to match the
pre-release-semver format introduced in commit 9459139 ("v0.0.3-alpha · ...").
Pre-existing test failure that wasn't caught at the time.
Storage:
- 30 new committed files under website/releases/ — 10 real (per-version) +
20 symlinks (5 tools × 4 partial/channel variants, plus alpha as a real
file by default).
- Initial state: stable v0.0.2 across all 5 tools; alpha/beta/v0.0/v0
symlinks all point at <tool>_v0.0.2.html.
- manifest.json deleted (no longer needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2dc9ad2 commit ("refactor: distribute via Codeberg release assets,
drop the upstream image") rewrote AGENTS.md and CLAUDE.md but left
several pre-existing references to the old write-to-website/releases
flow and the now-removed Containerfile / podman-compose / release-image.sh.
This sweeps the rest:
- CLAUDE.md
- drop "podman/podman-compose" from the zddc/ blurb (no Containerfile)
- drop the broken `podman build -t zddc-server zddc/` command
- rewrite the "Most-used commands" table so --release semantics match
actual behavior (tag + Codeberg upload, not file write)
- rewrite "Things that bite": replace "never write to website/releases/"
and the obsolete "alpha exception" bullet with the new rules
($CODEBERG_TOKEN required, dist files no longer force-tracked, etc.)
- rewrite the website/ description in "Repo shape" to reflect that
only index.html + manifest.json live there now
- ARCHITECTURE.md
- rewrite the website/ directory tree (no more <tool>_v*.html, _stable
symlinks, or _alpha/_beta files)
- rewrite "Channels" section: every cut now tags + uploads to Codeberg,
alpha/beta have .N counters and matching tags, no more in-place
overwrites
- rewrite the build-label table: dev builds carry the next-stable
target as a -alpha pre-release suffix with full timestamp + dirty
marker (was: "Built: <ts> BETA")
- update level-2 bootstrap description: resolves channel via
manifest.json, fetches /releases/<tag>/<asset>, not a flat URL
- update landing-tool description: ships only as Codeberg release
asset, not a committed website/releases/landing_v<X>.html
- AGENTS.md
- update website/ tree to the post-refactor layout
- replace the two-step podman build / podman-compose run blocks under
zddc-server with a Go build + go run quickstart (no container in
this repo)
- drop the "Containerfile uses a multi-stage build" note from the
"Notes" list (Containerfile is gone)
- drop the stale "landing/build.sh writes website/index.html" note —
website/index.html is now hand-edited, not produced by landing's
build
- README.md (top-level)
- tools table no longer links to /releases/<tool>_stable.html
(those URLs return 404 post-refactor); link to the releases page
once instead
- bootstrap/README.md
- update the "permanent pin" URL examples and CORS verification
snippet to use /releases/<tag>/<asset> URLs (Caddy → Codeberg)
instead of the old flat /releases/<tool>_<channel>.html pattern
- explain that channel resolution is via manifest.json now
- zddc/README.md
- rewrite Quick Start: download a release binary or build from source,
no `podman build`
- rewrite TLS examples to invoke ./zddc-server directly instead of
`podman run ... zddc-server` (image name no longer exists)
- mention ZDDC_INSECURE_DIRECT in the env-var table and the plain-HTTP
example — startup is refused without it on non-loopback binds
- replace the "Container image" section with "Distribution" (binaries
on Codeberg, no image) and the "Building" section with go build
instructions
- replace "Release Tagging" with documentation of zddc/release.sh
(the canonical replacement for release-image.sh, which is gone)
- shared/build-lib.sh
- fix the comment claiming "plain builds mirror to website/releases/"
— they don't anymore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the codeberg.org/varasys/zddc-server registry image, which had
no remaining consumer outside this shop. The two chart Dockerfiles
(tnd-zddc-chart) now compile zddc-server from source at build time,
fetching the right tag from a Codeberg release. release-image.sh,
zddc/Containerfile, and zddc/podman-compose.yaml are gone.
Build artifacts (HTML tools + zddc-server binaries) move from
website/releases/ in this repo to Codeberg release assets attached to
git tags. The website at zddc.varasys.io serves them by reverse-
proxying /releases/<tag>/<asset> to the corresponding Codeberg URL,
so consumers (zddc-use, level-2 bootstrap stubs, the chart
Dockerfiles) only ever talk to zddc.varasys.io.
Releases page becomes server-rendered static HTML regenerated on each
build via a single Codeberg API call. A small website/releases/manifest.json
maps <tool>-<channel> → tag for runtime channel resolution by zddc-use
and the level-2 stubs.
Files added:
- shared/publish-codeberg-release.sh — POSIX-sh helper that creates a
Codeberg release for a tag (sets prerelease flag from tag suffix)
and uploads/replaces release assets idempotently. Sourced by
build-lib.sh and zddc/release.sh.
- zddc/release.sh — replaces release-image.sh. Tags + cross-compiles
binaries via native Go (no podman needed; install Go) + uploads to
Codeberg release assets. No image build, no registry push.
Files modified:
- shared/build-lib.sh — promote_release tags + uploads via the helper
for stable AND alpha/beta now (alpha/beta were untagged before).
update_alpha removed; per-tool build.sh files no longer mirror to
website/releases/<tool>_alpha.html on plain dev builds.
- build.sh — prefers native go build over the old podman-based
cross-compile (which is gone with Containerfile). build_releases_index
queries the Codeberg API once and writes static HTML + manifest.json,
with graceful fallback when the API is unreachable.
- bootstrap/level2.html.tmpl — fetches manifest.json to resolve
channel → tag, then fetches the asset from /releases/<tag>/<asset>
(Caddy proxy). Replaces the old /releases/<tool>_<channel>.html flat
URL pattern. Operators with curl'd level-2 stubs need to re-issue
them — this is a breaking change.
- AGENTS.md, CLAUDE.md — rewritten to describe the new flow.
- .gitignore — releases/ artifacts now expected to be on Codeberg, not
committed locally.
NOT in this commit (deferred until $CODEBERG_TOKEN is provisioned):
- Backfilling existing tags as Codeberg releases.
- Cleanup commit: git rm-ing the existing artifacts in website/releases/.
Until backfill happens, those files are how operators with old
bootstrap stubs still get content. Once Codeberg has the assets,
drop them.
- The Caddy reverse-proxy config on zddc.varasys.io.
Operator-side changes (not in this repo):
- tnd-zddc-chart Dockerfile.prod and Dockerfile (dev) need updating
to compile from source rather than `FROM codeberg.org/...:stable`.
Done in a separate commit on that repo.
- Caddyfile rule for the /releases/<tag>/<asset> reverse-proxy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the build-counter version scheme (every alpha push monotonically
bumps the patch number, producing immutable :0.0.X tags that look
indistinguishable from stable releases) with proper semver pre-release
suffixes. Stable owns clean vX.Y.Z; alpha and beta carry
vX.Y.Z-{alpha,beta}[.N] indicating the next-stable target.
The next-stable target is the patch-bump of the latest clean
<prefix>-vX.Y.Z tag. Counter N is per-channel (alpha and beta count
separately) and resets when a new stable advances next-patch. Used
only for zddc-server image tags, where every release is git-tagged;
HTML tools omit the counter since alpha/beta cuts there don't tag.
release-image.sh:
- New CLI: sh release-image.sh [alpha|beta|stable] [<version>].
- Default channel alpha. Version arg only valid (and only optional)
for stable.
- Auto-derives the version via next_prerelease for alpha/beta, and
patch-bump for unspecified stable.
- Now creates the git tag itself (the auto-derived version is no
longer something the operator can predict in advance), but does
not push — operator finishes with `git push --tags`.
shared/build-lib.sh:
- Add next_prerelease(channel, tag_prefix) helper.
- compute_build_label embeds v<next-stable>-{alpha,beta} in the
on-page label for plain and --release alpha|beta builds.
- Plain builds: v<next-stable>-alpha · <ts> · <sha>[-dirty]
--release alpha: v<next-stable>-alpha · <date> · <sha>
--release beta: v<next-stable>-beta · <date> · <sha>
--release [<version>]: v<X.Y.Z> (clean stable, unchanged shape).
Pre-release semver ordering (vX.Y.Z-alpha.1 < vX.Y.Z-alpha.2 <
vX.Y.Z-beta.1 < vX.Y.Z) is honored by registry tag sorting,
git tag --sort=-v:refname, sort -V, npm, cargo — so consumers can
pin or compare versions without surprises.
Existing zddc-server-v0.0.{3..7} git tags and registry tags are
audit history; not rewritten. Going forward, alpha/beta cuts produce
v0.0.8-{alpha,beta}.N format, and clean v0.0.8 is reserved for a
deliberate stable promotion.
freshen-channel needs no code change. It runs --release <channel>
inside a worktree at the latest stable tag, where the build-lib.sh
at that tag is still the old version producing old-format labels;
the first stable cut after this commit will propagate the new format
to subsequent freshens (per the existing "build pipeline at the tag"
reproducibility policy).
AGENTS.md and CLAUDE.md updated.
Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:
- landing: substantial rework of the project picker (sortable/filterable
table, presets refactor, ?projects= filter, ?v= channel propagation,
loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
*_alpha.html — these are produced by `sh build.sh` and per project
convention are committed alongside the source changes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier symlink approach (commit 03f83ad) broke under the canonical
deployment shape. The Caddy systemd unit at
/etc/containers/systemd/caddy.container mounts only
/home/user/src/zddc/website:/usr/share/caddy/zddc:ro
into the Caddy container, so a symlink at
website/releases/landing_alpha.html → ../../landing/dist/index.html
resolves to /usr/share/caddy/landing/dist/index.html inside the
container — a path that simply doesn't exist there. Result:
GET https://zddc.varasys.io/releases/landing_alpha.html → 404, and
the dev cluster's level-2 stub failed to load.
Revert update_alpha() to a plain copy. Trade-off goes back to: every
dev build dirties the corresponding _alpha.html in git. Commit
alongside source changes (alpha is mutable channel anyway) or
git checkout to discard. cp follows symlinks at the destination, so
the helper now `rm -f`s the dest before copying — handles the
symlink-to-file transition cleanly.
Updates AGENTS.md and CLAUDE.md to describe the copy semantics and
the volume-mount constraint that motivates them. Five _alpha.html
files convert from symlinks back to regular files (typechange).
End-to-end verified: curl https://zddc.varasys.io/releases/landing_alpha.html
returns 200 (30177 bytes) after the rebuild.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every plain `sh tool/build.sh` invocation now reasserts a relative symlink
website/releases/<tool>_alpha.html → ../../<tool>/dist/<tool>.html so the
alpha hyperlinks always serve whatever dist currently holds. Idempotent — git
sees no churn on rebuild. `--release alpha` still wins by overwriting the
symlink with a real "alpha · <date> · <sha>" file; the next plain build
re-symlinks it.
Five existing alpha files become typechanges (regular file → symlink) — the
one-time migration cost. The reassertion survives deployment because the
website is served directly from the working tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'latest' label for the current-stable channel was inconsistent
with the channel set we use elsewhere (alpha / beta / stable). Rename
to 'stable' so URLs, file names, zip names, and image tags all line
up with the channel terminology used in the bootstrap, AGENTS.md
discipline rules, and chart consumers.
File / artifact renames
- website/releases/<tool>_latest.html → <tool>_stable.html (5 files)
- website/track-latest.zip → track-stable.zip
- shared/build-lib.sh: promote_release writes/refreshes _stable.html
- bootstrap/level{1,2}.html.tmpl: channels map drops 'latest', keeps
'stable' as the canonical name. ?v=stable is now the explicit way
to switch to current-stable for one request (alongside ?v=alpha,
?v=beta, and ?v=X.Y.Z).
- build.sh: install.zip sources from <tool>_stable.html; emits
track-stable.zip instead of track-latest.zip.
Container image (.woodpecker.yml rewritten)
- Tag publishing now cascades:
zddc-server-vX.Y.Z → :X.Y.Z, :stable, :beta, :alpha, :latest
zddc-server-vX.Y.Z-beta.N → :X.Y.Z-beta.N, :beta, :alpha
zddc-server-vX.Y.Z-alpha.N → :X.Y.Z-alpha.N, :alpha
- :stable, :beta, :alpha are now first-class channel pointers; chart
consumers (e.g. tnd-zddc-chart) can FROM :beta for dev and FROM
:stable for prod.
- :latest kept as an alias for :stable per Docker convention.
Documentation sweep
- AGENTS.md, ARCHITECTURE.md, CLAUDE.md, README.md
- bootstrap/README.md, zddc/README.md
- website/index.html, website/zddc-server.html
- transmittal/template.html, transmittal/README.md
all updated to reference _stable.html / track-stable.zip / the
'stable' channel name. ARCHITECTURE.md's manual freshen example
points at ./freshen-channel instead of the old git-checkout snippet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.
See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.