Commit graph

51 commits

Author SHA1 Message Date
050902fa9e chore: elevation slot in every tool + docs + helper file splits + smell cleanup
Polish pass after the big refactor in 2d114fc.

== Header elevation slot propagated ==

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

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

== Docs ==

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

== Helper file splits ==

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

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

== Smell cleanup ==

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:42 -05:00
e5ba2b6168 fix(tables): handle bare-directory URLs served as default_tool
Visiting `/Project-1/archive/PartyA/mdl` (no trailing slash) errored with
`Unrecognized table URL` because tableNameFromUrl only matched
`…/<rowsdir>/table.html`. The cascade declares `default_tool: tables` at
`archive/<party>/mdl`, so the server serves the tables HTML at the bare
directory URL — a shape the client didn't recognize.

Two coordinated fixes:

- shared/zddc-source.js `pathToDir`: was over-eagerly stripping the last
  segment when the URL didn't end in `/`. Now checks whether the last
  segment contains a dot — file URLs strip to parent (original behavior
  preserved), bare-directory URLs append the missing slash. Only call
  site is detectServerRoot, so blast radius is contained.
- tables/js/context.js `tableNameFromUrl` + `rowEditUrl`: accept both
  legacy `…/<rowsdir>/table.html` and the new bare-directory shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:06 -05:00
6dca32b282 chore(build-label): drop three-word slug — use full timestamp + short SHA for alpha/beta cuts
The on-page label for `--release alpha|beta` cuts used to read
`vX.Y.Z-channel · YYYY-MM-DD · word-word-word`. The three-word slug
(derived deterministically from the source SHA via shared/build-words.txt)
was meant to make "is this the same build you emailed about?" a glance
check, but the cute-words layer turned out to be more confusing than
clarifying — testers prefer a real timestamp + short SHA.

New label shape, identical to plain dev builds:

  vX.Y.Z-channel · YYYY-MM-DD HH:MM:SS · <short-source-sha>[-dirty]

Helper renamed from _source_commit_slug to _source_commit_short_sha,
returning the short SHA of the source commit (walking past any
`chore(embedded): cut …` commit at HEAD so a re-cut on unchanged
source produces the same SHA). The wordlist file is no longer
referenced and is removed; tests/build-label.spec.js's regex
simplified to require the full timestamp + SHA form.
2026-05-13 11:14:35 -05:00
e7f6334daa chore: retire mdedit tool — markdown editor lives in browse now
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.

Removed:

  • mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
  • zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
  • tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
  • mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
    switch case in EmbeddedBytes)
  • "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
    error-message app list
  • "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
  • mdedit case in tests (handler_test.go, validate_test.go,
    zddchandler_test.go) — test fixtures now use browse/classifier
  • mdedit from build (per-tool build.sh loop, tool-list literals,
    composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
  • mdedit from freshen-channel's tool list and usage banner
  • mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
    Markdown Editor section in ARCHITECTURE.md rewritten to point at
    browse/js/preview-markdown.js
  • mdedit from CLAUDE.md, README.md, zddc/README.md tool lists

Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
2026-05-13 10:34:31 -05:00
b34edcecac feat(browse): markdown editor — editable YAML front matter + DOCX/HTML/PDF download buttons
Two improvements to browse's preview-markdown plugin so it can replace
the standalone mdedit tool:

1. **YAML front-matter editing.** The FM pane above the outline used to
   render a read-only <dl> of parsed keys — sparse and unusable when
   the file had no envelope yet. It's now a dedicated <textarea> that's
   always present. On load, parseFrontMatter() splits the `---\n…\n---`
   envelope off the body: the body feeds Toast UI Editor, the envelope
   feeds the textarea. On save, assembleContent() recombines them.
   Dirty tracking covers both halves via a SHA-256 of the assembled
   bytes. The shell mirrors mdedit's old layout (FM textarea top,
   outline below) but the FM pane is now always functional, eliminating
   the "empty pane over the TOC" problem.

2. **Download as DOCX / HTML / PDF.** When the file handle is HTTP-
   backed (server mode) and the file is a .md, three buttons appear in
   the info header next to Save. Clicking one fetches the server's
   ?convert=<fmt> endpoint and triggers a browser download with a
   clean filename (foo.md → foo.docx). Auto-saves the buffer first if
   dirty so the converted bytes reflect what's on screen.

Helper at window.zddc.source.downloadConverted (shared/zddc-source.js)
so other tools — archive, transmittal — can reuse the same flow later.
Friendly error messages map HTTP 503 / 422 / 504 to actionable toasts.
2026-05-13 10:32:38 -05:00
2dc2d032a0 feat(archive,browse): treat .zip transmittal folders as folders + shared zip adapter
New shared/zip-source.js: a ZipDirectoryHandle / ZipFileHandle pair
that exposes a JSZip instance behind the File-System-Access surface
(values/entries/keys, getDirectoryHandle/getFileHandle, getFile) —
read-only, with a zip-slip guard. Mirrors shared/zddc-source.js's
HTTP polyfill. Wired into archive's and browse's build.sh (both
already bundle JSZip).

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

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

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

Now cascade-driven end-to-end:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:34:56 -05:00
02bdf851c1 fix(shared/nav): stage strip uses no-slash targets so each stage opens its tool
The shared header strip pointed Working/Staging/Reviewing at the slash
form (working/, etc.), which now serves browse per the slash/no-slash
convention established earlier. The user expected those links to open
the stage's tool (mdedit for working, transmittal for staging, etc.) —
which is what the no-slash form serves.

Also drops the .html suffix from the archive target: <project>/archive
(no slash) → archive tool, same as the other stages. The currentStage
recognizer still accepts /archive.html as a fallback for any direct
URLs that survive in bookmarks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:11:00 -05:00
6d72f5c770 feat(responsive): shared narrow-viewport baseline for the header chrome
Only transmittal had any @media (max-width) rules; the other seven
tools silently break below ~900px. Adds a baseline shared rule that
every tool inherits — desktop-first stays the same, but a tablet in
landscape or a window split next to a document remains usable.

@media (max-width: 800px):
  - tighter header padding + gaps
  - .app-header__title drops 18px → 16px
  - .build-timestamp inside .header-title-group hidden (it's
    traceability info, not a primary affordance — still reachable
    via help panel)
  - header text buttons get a smaller padding so they fit

@media (max-width: 480px) phone-width:
  - .app-header switches to column layout
  - .header-left and .header-right each span full width with
    justify-content: space-between

prefers-reduced-motion was already covered for the page-load stagger.

Each tool can still override in its own css/layout.css; this is the
shared floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:12:17 -05:00
1f03631d2d feat(motion): staggered page-load reveal on header chrome
The header is the first thing a user sees. A short staggered fade-in
(logo → title → action button → right-side icons over ~360ms) turns the
instant-pop-in feel into a subtle "the tool is composing itself" beat.

Pure CSS @keyframes (no JS), cubic-bezier(0.2, 0.7, 0.2, 1) for the
"settle in" easing curve. Respects prefers-reduced-motion. Total budget
~260ms before everything is visible — well under the threshold where it
becomes a perceptible delay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:10:45 -05:00
6260aa4860 feat(typography): bake IBM Plex Sans + Source Serif 4 into every tool
System-default font stack ('-apple-system, BlinkMacSystemFont, Segoe UI,
…') is the textbook generic admin-tool look. The tools have a real point
of view (engineering documents, traceability, immutability); the
typography should reflect that.

Picks:
  --font          → IBM Plex Sans (400 + 600). UI body text. Distinctive
                    engineering sans with tabular nums and proper figures.
  --font-display  → Source Serif 4 (600). Headings, page titles,
                    .app-header__title. Reads as "document" not "UI label."
  --font-mono     → unchanged. Platform mono fonts are already excellent
                    and engineering tools rarely benefit from a custom mono.

Wiring:
  - Raw .woff2 files live in shared/fonts/ (~60 KB total, latin subset,
    SIL OFL 1.1 — both families)
  - shared/fonts.css is base64-inlined data URIs for those three fonts
    (~80 KB after b64 overhead). Generated once from the snippet in
    shared/fonts/README.md.
  - Every tool's build.sh prepends shared/fonts.css before shared/base.css
    so @font-face is parsed before any rule references the family names.
  - Headings (h1-h6) and .app-header__title now use var(--font-display);
    .app-header__title bumped 17→18px and letter-spacing reset since the
    serif doesn't need the original sans-text tightening.
  - table/code/.tabular-nums get font-variant-numeric: tabular-nums so
    tracking-number columns align vertically.

"Ship the record player with the record": zero CDN dependency at render
time. Tools render identically offline and online. Per-tool dist sizes
grew by ~80 KB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:09:59 -05:00
8be6c4d98b feat(shared): route window.alert() to non-blocking toast
There are 76 alert() call sites across the eight tools — three different
ad-hoc error-surfacing patterns (alert, console.error, classifier's own
showToast). Touching every site is a sweep with no judgment payoff:
every alert is "something went wrong, the user should know," which is
exactly what toast at level='error' is for.

Shim is one if-block at the bottom of shared/toast.js. It saves the
native window.alert as window.alertNative (so any truly modal-blocking
call site can opt back in by name), then replaces window.alert with a
function that forwards through window.zddc.toast(msg, 'error'). Effect
is global — every existing alert in every tool becomes a non-blocking,
ARIA-announced (aria-live=assertive) toast that the user can click to
dismiss.

handler/tables.html refreshed by ./build as a side effect (it bakes the
current tables/dist/ into the binary every build).

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:22:03 -05:00
7d4d2dc9a2 feat(browse): two-pane shell + markdown plugin + grid mode (Phases A/B/C/D)
Reshape browse from "tree-as-table with popup preview" into a unified
file-experience tool with three layered behaviors:

  Phase A — Two-pane shell
  Phase B — Markdown plugin (Toast UI inline)
  Phase C — Grid mode (classifier workflow)
  Phase D — Deprecation banners on standalone classifier + mdedit

= Phase A: two-pane shell + lightweight preview plugins =

Browse's table view becomes a tree-pane on the left + preview-pane on
the right with a draggable resizer. Click a folder → expand inline.
Click a file → render in the right pane. The previous popup window
becomes an explicit "⤴ Pop out" button in the right-pane header for
users with a second monitor.

Preview rendering reuses shared/preview-lib.js (PDF iframe, image
<img>, TIFF, ZIP listing, text <pre>). Unknown types show a download
link. browse/js/preview.js refactored into renderInline (default) +
renderInPopup (Pop out button); both share the same plugin
dispatch logic.

Filter rows were already removed earlier this session. Sort columns
likewise — the tree is alphabetical by default; the underlying
setSort API still exists for future re-introduction.

= Phase B: markdown plugin =

New browse/js/preview-markdown.js: when a .md or .markdown file is
clicked, the right pane mounts a Toast UI editor (initial-value =
file contents) with a small toolbar containing Save + dirty indicator
+ status text. Save sends PUT through the file API for server-mode
files; non-server sources are read-only for now (deferred to a
follow-up that wires zddc-source.js writes too). Ctrl+S / Cmd+S
inside the editor saves.

Toast UI Editor (~700 KB JS + ~160 KB CSS) was previously bundled
only in mdedit/vendor/. Moved to shared/vendor/ so browse and mdedit
both pull from one location.

= Phase C: grid mode =

View-mode toggle [Browse | Grid] in the toolbar. Grid mode loads the
classifier tool as an iframe scoped to the current directory (server
mode at working/staging/incoming locations) — classifier's full
bulk-rename workflow without leaving browse. v1 implementation; a
future iteration could bundle classifier's modules directly into
browse for tighter integration. Hostile cases (file:// origin, paths
outside working/staging/incoming) show a friendly explanation
instead of a blank iframe.

new browse/js/grid.js handles the activation logic.

= Phase D: deprecation banners =

mdedit and classifier standalones gain a "this tool is being absorbed
into Browse" advisory banner. Both standalones remain fully
functional and continue to ship — they're useful for offline single-
file editing and air-gapped environments. The banner just points
users toward the unified browse experience.

= Files =

  + browse/js/preview-markdown.js   (markdown plugin)
  + browse/js/grid.js               (grid-mode plugin)
  M browse/template.html            (two-pane layout, view toggle, banners)
  M browse/css/tree.css             (two-pane CSS, replaces table styles)
  M browse/js/init.js               (state additions: selectedId, viewMode)
  M browse/js/tree.js               (rowHtml: <tr>+<td> → <div>)
  M browse/js/preview.js            (renderInline / renderInPopup split)
  M browse/js/events.js             (toggle wiring, resizer, click handlers
                                     adapted from <table> to <div>)
  M browse/build.sh                 (Toast UI vendor + new modules)
  R mdedit/vendor/toastui-*         → shared/vendor/  (one bundle, two tools)
  M mdedit/build.sh                 (paths)
  M mdedit/template.html            (deprecation banner)
  M classifier/template.html        (deprecation banner)
  M tests/browse.spec.js            (selectors updated for new layout +
                                     new "click file → preview" test)

Bundle sizes after this commit:
  browse:     ~1020 KB  (was ~290 KB; added Toast UI ~700 KB)
  classifier: ~1470 KB  (unchanged from prior baseline)
  mdedit:     ~2140 KB  (unchanged; vendor location moved but not added)

What's deferred:
  - TOC + front-matter pane in browse's markdown plugin (mdedit has
    these; browse v1 uses just the editor).
  - FS-API writes from browse's markdown plugin (server PUT works).
  - Classifier modules bundled directly into browse (v1 uses iframe).
  - Sort UI in the new tree (model still supports it; no widget yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:46:51 -05:00
d6206b03e7 feat(shared): bake xlsx + utif + jszip + docx-preview into every tool
Removes every runtime CDN load. The "ship the record player with the
record" philosophy: a downloaded .html file works offline against any
file the user can open, with no network dependency at runtime.

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

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

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

Per-tool changes:

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:09:38 -05:00
677ac01b32 refactor(shared): consolidate empty-state into shared chrome (BEM)
Three tools (archive, browse, classifier) independently implemented
an empty-state pattern with three different CSS class naming
conventions and slightly different rules:

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

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

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

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

Per-tool changes:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:21:07 -05:00
351b6555b7 fix(shared): .header-left/.header-right belong in the shared chrome rule
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>
2026-05-10 14:09:23 -05:00
7fd96c7c78 feat(shared): clickable logo links every tool's header to project home
The .app-header__logo SVG was decorative on every tool. Web's
strongest convention is "click logo → go home" — so users tapping
it expecting that fallback got nothing. Now the logo is wrapped in
an anchor whose href reflects the URL the page was loaded from:

  file://                    → no wrap (no server home to point at)
  /                          → wrap, href=/         (deployment root)
  /index.html / /<tool>.html → wrap, href=/         (root, no project)
  /<project>/...             → wrap, href=/<project> (project landing)

The wrap happens client-side at DOMContentLoaded via shared/logo.js,
loaded by every tool's build.sh after toast/nav. Idempotent — a
template-supplied anchor or a second mount call is a no-op.

The companion shared/logo.css adds a subtle hover/focus affordance
(opacity 0.82, focus ring) so the logo reads as clickable without
otherwise altering its visual weight. Tools opt out by setting
window.zddc.logo.disabled = true before DOMContentLoaded (e.g. for
deployments that pin the logo to an external destination).

Five Playwright tests (tests/logo.spec.js) lock the contract:
no-wrap on file://, href=/ at root, href=/<project> in project
subtree, aria-label matches target, idempotent re-mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:34:28 -05:00
45005d164e feat(zddc-server): reviewing/ virtual aggregator + mdedit at the URL
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>
2026-05-09 21:37:08 -05:00
e9a7749153 style(nav): mount stage strip above the header instead of below
Project-level chrome (where in the project) belongs OUTSIDE
tool-level chrome (which tool / theme / help) in reading order.
Stage strip now sits above .app-header rather than after it.

One-line behavioral change in shared/nav.js (insertBefore(strip,
header) instead of insertBefore(strip, header.nextSibling)) plus
the matching nav.spec.js assertion. Visual hierarchy is now:

  ┌─────────────────────────────────────────┐
  │ projA / Archive · Working · …  (strip)  │  ← outer scope
  ├─────────────────────────────────────────┤
  │ [logo] ZDDC Archive  v…   [theme][help] │  ← tool chrome
  ├─────────────────────────────────────────┤
  │  page content                           │
  └─────────────────────────────────────────┘

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:04:41 -05:00
3baf160c88 style(shared,archive,browse,mdedit): replace inline styles with CSS
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>
2026-05-09 18:48:44 -05:00
db68dc9eec build(channels): correct wordlist path for tool-side build invocations
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
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>
2026-05-07 12:12:46 -05:00
1dafc97d8e build(channels): swap source-SHA in beta label for a three-word slug
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
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>
2026-05-07 12:11:23 -05:00
32b95c02b8 build(channels): include source SHA in beta on-page label
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>
2026-05-07 12:08:01 -05:00
8f07b47a0b fix(build,ci): auto-commit embedded refresh on beta cuts; pin chart to HEAD
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>
2026-05-05 20:48:09 -05:00
9ca36f25d8 feat(tables): new sortable/filterable grid tool for directories of YAML files
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>
2026-05-05 20:32:01 -05:00
3115e388fc feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders
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>
2026-05-05 15:58:04 -05:00
9481122570 perf(tools): vendor jszip + docx-preview for archive/transmittal/classifier
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.
2026-05-04 07:49:17 -05:00
22c142e45a chore(headers): standardize across all 7 tools
Bring every tool's header in line with archive's pattern:

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

Changes per tool:

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

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

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

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

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

Cross-cutting:

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

Verified all 7 tools in headless Chromium: header structure correct,
build label red on non-stable cuts, help panel opens + closes via
button + Esc.
2026-05-04 07:49:17 -05:00
582db6d86d feat(browse): vendored JSZip, SVG home icon, auto-filter rows
- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
  the browse build instead of CDN-loading. Eliminates the failure
  mode where ZIP rows can't expand because the CDN script doesn't
  load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
  spreadsheet-style auto-filter rows in <thead>:
    - 📄 row: file-name filter + extension filter
    - 📁 row: folder-name filter
  Each input uses shared/zddc-filter syntax (substring/!negate/
  ^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
    - file matches keep their ancestor folders visible (path-to-hit)
    - folder match keeps its descendants visible
    - filters compose (file ∧ folder ∧ ext) so combinations narrow
  Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
  that tints with currentColor.
2026-05-03 21:35:15 -05:00
fb13ff4fd8 feat(browse): generic directory listing tool — default at folder URLs
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s
A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.

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

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

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

Wiring:
  - browse/ added to top-level ./build's per-tool list, embed
    block, versions.txt, and the lockstep release commit + tag set.
    All seven tools (archive, transmittal, classifier, mdedit,
    landing, form, browse) advance together on stable cuts.
  - shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
    verify_channel_links's per-tool loop.
  - zddc/internal/apps/embed.go: //go:embed browse.html +
    EmbeddedBytes("browse") case.
  - zddc/internal/apps/availability.go: browse available at every
    directory (same as archive).
  - zddc/internal/apps/handler.go: MatchAppHTML routes
    /<dir>/browse.html → 'browse'.
  - zddc/internal/handler/directory.go: when a directory request
    arrives with Accept: text/html and no index.html exists,
    serve the embedded browse.html bytes (with a JSON-fallback
    if the embedded slot is empty during bootstrap).
2026-05-03 19:56:51 -05:00
b47b5222af fix(build): remove skip-if-unchanged in lockstep stable cut
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.
2026-05-03 18:37:03 -05:00
8dbd002727 fix(build): commit embedded artifacts before tagging; alpha never bakes in
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.
2026-05-03 16:44:39 -05:00
a02a26d3c2 feat: form-data system v0 (sixth tool + zddc-server endpoints)
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
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>
2026-05-02 20:12:16 -05:00
7570fb7494 refactor: separate website repo + deploy-host model
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>
2026-05-02 09:14:40 -05:00
76820fa8dd chore: split website out into orphan branch + worktree
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>
2026-05-02 07:52:20 -05:00
9fce18cd45 feat: lockstep release infra + cascade/.archive fixes + profile perf + page redesign
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>
2026-05-01 20:11:38 -05:00
4ede42010a feat(zddc-server): CLI flags, --version, CWD-default ZDDC_ROOT
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>
2026-05-01 15:43:31 -05:00
f01a177b73 feat(html): TIFF and ZIP listing previews + favicon in app headers
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>
2026-05-01 15:23:26 -05:00
408a1a0571 refactor: HTML tools live in website/releases/ as static files + symlink hierarchy
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>
2026-04-30 09:40:16 -05:00
bdac8dc4fb docs: clean up drift left over from the Codeberg release-assets refactor
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>
2026-04-30 08:01:20 -05:00
2dc9ad240c refactor: distribute via Codeberg release assets, drop the upstream image
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>
2026-04-29 21:18:26 -05:00
94591397cf build: pre-release semver for alpha/beta channels
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.
2026-04-29 17:32:35 -05:00
c95f07966d feat(tools,build): in-flight HTML-tool reworks and build-infra updates
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>
2026-04-29 12:52:27 -05:00
714faf60f2 fix(build): copy dist into website/releases/<tool>_alpha.html instead of symlinking
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>
2026-04-28 18:41:27 -05:00
03f83ad211 feat(build): symlink website/releases/<tool>_alpha.html into dist instead of copying
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>
2026-04-28 14:01:41 -05:00
67f794e6d0 refactor: rename channel 'latest' to 'stable' across all artifacts
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>
2026-04-28 09:30:24 -05:00