ZDDC/ARCHITECTURE.md
ZDDC 85521b98de feat(server): case-insensitive URL canonicalization at dispatch
URLs are now case-insensitive against the on-disk casing under
ZDDC_ROOT, with a lowercase-wins tiebreak when sibling case variants
exist. File and folder names preserve case on disk — the change is a
pure URL→FS-name mapping; nothing renames anything.

internal/fs/resolve.go ResolveCanonical walks segments left-to-right
under fsRoot. Per segment: try lowercase first (canonical / cheap
lstat fast-path), then exact-case, then readdir+CI scan with the
all-lowercase variant winning the tiebreak. Walk stops at the first
segment that doesn't exist on disk so virtual prefixes (.archive,
.profile, .tokens, .auth) and 404 paths flow through with their tail
preserved verbatim. Path-escape safety check on the resolved abs
path matches the existing safeJoin pattern.

Wired in at the top of cmd/zddc-server/main.go dispatch(), which
rewrites r.URL.Path before any handler runs. Downstream handlers
(plus their existing safeJoin calls and the cascade walker) pick up
canonical case automatically — no per-handler changes. The ACL
cascade benefits from this for free since EffectivePolicy is keyed
by the now-canonical absolute path.

internal/handler/middleware.go AccessLogMiddleware snapshots the
as-typed URL path before the rewrite. The audit log's `path` field
records what the client actually sent; a `resolved_path` field is
added only when canonicalization changed it. Operators reading the
log can see both the raw request and what was served.

Lowercase as the project-wide canonical convention is already
honoured by the auto-created folders in internal/zddc/ensure.go
(working/, staging/, archive/<party>/incoming/) and the server's
own state dirs (_app/, .zddc.d/tokens/, .zddc.d/outbox/,
.zddc.d/logs/). Operators who drop a Mixed-Case-Folder/ on disk
keep that casing — the resolver finds it via the readdir tier.

Performance: the lowercase-first lstat is one syscall on the hot
path. Only mismatches (mixed-case URL where on-disk is also
mixed-case) pay the readdir+EqualFold scan, and Linux page-caches
small-dir readdirs aggressively. Apache mod_speling uses the same
"try then fallback" pattern.

Tests:
- internal/fs/resolve_test.go — 9 unit tests: exact-case,
  mixed-case-URL-with-lowercase-on-disk, mixed-case-URL-with-
  mixed-case-on-disk, both-cases-exist-lowercase-wins, nonexistent
  segment preserves remainder, file-segment terminates walk, escape
  rejection, trailing-slash normalization, root.
- cmd/zddc-server/main_test.go TestDispatchCaseInsensitiveURL —
  end-to-end through the dispatcher with sibling Archive/ and
  archive/ on disk; all four URL casings of the same path serve the
  lowercase variant's content (proves the tiebreak fires through
  every layer).
- Full Go suite green.

Docs: AGENTS.md gains a "URL handling" subsection in the
zddc-server section; ARCHITECTURE.md security-model table gains a
"URL canonicalization" row.

Out of scope (separate decisions, can revisit if needed):
- ACL glob CI-matching. If .zddc rules use mixed-case URL globs,
  they won't match the canonical lowercase URL. Workable today by
  writing rules in lowercase. Touches a different package.
- Redirect-to-canonical (303). Server serves under whichever case
  the client used; canonicalization is internal. Could 301 to
  canonical for SEO/bookmark hygiene as a follow-up.
- Client-mode (proxy/cache). Only master mode is wired so far.
  Cache-handler CI lives in internal/cache/cache.go cachePathFor
  and is a separate code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:09:47 -05:00

56 KiB

ZDDC Architecture

This document is the single authoritative reference for how ZDDC tools are designed and built. It covers the shared single-file HTML application pattern, the build system, tool-specific architectural decisions, and contribution guidelines.


Why Single-File HTML Applications

Every ZDDC tool compiles to a single self-contained .html file — no servers, no installers, no subscriptions.

Principle Rationale
Reliability Opens in any modern Chromium-based browser without network access or external services
Portability Can be emailed, archived, or deployed to air-gapped environments with no tooling
Auditability Source, embedded data, and output travel together, satisfying ZDDC traceability requirements
Longevity Static assets remain functional long after build environments have changed
Simplicity A single .html file eliminates deployment steps and brittle dependency chains

Repository Structure

Every HTML tool follows the same directory layout:

tool/
  README.md        # Feature scope, UI design, domain rules, help content
  css/             # Logically separated stylesheets (one responsibility per file)
  js/              # Vanilla ES modules (one responsibility per file)
  template.html    # Shell markup with {{PLACEHOLDER}} markers for development
  build.sh         # Inlines css/ and js/ into dist/tool.html
  dist/
    tool.html      # Generated output — never edit this manually

Website files (what zddc.varasys.io serves) live on a separate Codeberg repo (codeberg.org/VARASYS/ZDDC-website, typically cloned at ~/src/zddc-website/) for hand-edited content, plus the deploy host's /srv/zddc/ for the assembled live site. The system Caddy bind-mounts /srv/zddc/. ./deploy rsyncs both into it.

~/src/zddc-website/  (clone of codeberg.org/VARASYS/ZDDC-website)
  index.html                                          # hand-edited intro page + install snippets (root URL)
  reference.html                                      # hand-edited file-naming convention spec
  css/, js/, img/                                     # hand-edited static assets
  README.md, LICENSE                                  # repo housekeeping
  # NO releases/ — release artifacts are NOT in any git history.

~/src/zddc/dist/release-output/  (gitignored, produced by ./build alpha|beta|release)
  index.html                                          # download page, regenerated by build
  <tool>_v<X.Y.Z>.html                                # real per-version HTML (immutable)
  <tool>_v<X.Y>.html       → ...                      # symlink: latest patch within X.Y.*
  <tool>_v<X>.html         → ...                      # symlink: latest within X.*.*
  <tool>_stable.html       → ...                      # symlink: current stable HTML
  <tool>_beta.html         → ...                      # symlink to stable (or real bytes when active beta dev)
  <tool>_alpha.html        → ...                      # symlink to beta/stable (or real bytes when active alpha dev)
  zddc-server_v<X.Y.Z>_<platform>                     # real per-version cross-compiled binary (raw bytes, no LFS)
  zddc-server_v<X.Y>_<platform>     → ...             # symlink chain (mirrors the HTML cascade per platform)
  zddc-server_v<X>_<platform>       → ...
  zddc-server_<channel>_<platform>  → ...             # channel mirror per platform
  zddc-server_<X>.html                                # generated stub: cell link → fans out 4 platform downloads

/srv/zddc/  (deploy host; Caddy bind-mount)
  index.html, reference.html, css/, js/, img/         ← rsync'd from ~/src/zddc-website/
  releases/                                           ← rsync'd from ~/src/zddc/dist/release-output/

<tool> ∈ {archive, transmittal, classifier, mdedit, landing}. <platform> ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.

Every URL under /releases/ resolves directly via the symlink chain — no manifest.json, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: :1.2.3 is pinned, :1.2 floats, :1 floats further, :stable floats furthest, and :beta / :alpha are mutable channel mirrors that overwrite in place.

zddc-server binaries are reproducible from a tag, not in git./build alpha|beta|release cross-compiles them into dist/release-output/, ./deploy rsyncs them to /srv/zddc/releases/, Caddy serves from there. Older versions: git checkout zddc-server-v0.0.8 && ./build release 0.0.8. The helm/zddc-server-{prod,dev,cache}/ charts build from source via init container, but operators who want a prebuilt binary just curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64. The single cell link per release points at zddc-server_<X>.html, a small generated stub that surfaces all four platform downloads.

To preview a build locally, open dist/tool.html directly via the dev server. To publish on zddc.varasys.io, cut a release with ./build alpha|beta|release and then ./deploy.

Vendor dependencies (bundled third-party libraries) live in tool/vendor/ if present. The build script is responsible for inlining them into the output.


Documentation ownership

Each topic has exactly one authoritative home; everything else links to it.

Topic Single home Linked from
What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets ~/src/zddc-website/index.html (hand-edited intro for zddc.varasys.io/, in the ZDDC-website repo) repo README.md, zddc/README.md
File-naming convention spec (status codes, modifiers, folder format) ~/src/zddc-website/reference.html repo README.md, in-tool help text
Versions + channel builds index of every tool dist/release-output/index.html (regenerated by ./build; deployed to /srv/zddc/releases/index.html) website intro nav, "Browse all versions" link
Customer-deployment install (zddc-server binary embeds current-stable tools; .zddc apps: cascade overrides; cache at <root>/_app/) zddc/README.md "Apps: virtual tool HTMLs" section website intro, AGENTS.md
zddc-server operations: env vars, ACL syntax, .archive URLs, container vs binary zddc/README.md AGENTS.md, website intro
Build / release / channel commands AGENTS.md repo README.md ("see AGENTS.md")
Architecture & internal patterns ARCHITECTURE.md (this file) AGENTS.md
Per-tool internal design quirks <tool>/README.md (linked from website intro tool cards)

index.html in the ZDDC-website repo (working dir ~/src/zddc-website/index.html) is hand-edited static content (analogous to reference.html), not the landing-tool output. The install section points operators at two paths: local (download a .html file from /releases/) and server (run zddc-server; current-stable builds of all six tools are baked into the binary at compile time via //go:embed). The landing tool's released bytes live at /srv/zddc/releases/landing_v<X.Y.Z>.html (rsync'd from dist/release-output/); the embedded copy serves at the deployment root by default. The public website at zddc.varasys.io/ is the same hand-edited index.html — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).

When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to reference.html instead.


Build System

How It Works

Each HTML tool's build.sh:

  1. Reads CSS files in declaration order, concatenates them
  2. Reads JS files in declaration order, concatenates them
  3. Processes template.html with awk, replacing {{PLACEHOLDER}} markers with the concatenated content and stripping CDN <script>/<link> tags
  4. Writes the result to dist/tool.html
  5. If --release <channel-or-version> was passed, calls promote_release to write into dist/release-output/ (per-version file + symlink updates for stable; channel mirror overwrite for alpha/beta).

The top-level ./build at the repository root is the canonical lockstep entry point. It:

  1. On a channel/release cut, seeds dist/release-output/ from /srv/zddc/releases/ (preserving symlinks) so the bundle is a complete intended-live snapshot, not a sparse one-channel diff. Cascades and the verifier downstream see the same world the live site has.
  2. Forwards --release [version|alpha|beta] to every HTML tool's build, computing a coordinated next-stable target via _coordinated_next_stable (max of every tool's latest tag + 1) when no explicit version is given.
  3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
  4. On a channel/release cut, calls promote_zddc_server to copy the freshly cross-compiled binaries into dist/release-output/ with the matching symlink chain (one set per platform) and tag zddc-server-v<X.Y.Z> alongside the six HTML-tool tags (stable cuts only).
  5. Calls write_zddc_server_stubs_all to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in dist/release-output/.
  6. Regenerates dist/release-output/index.html as the action-first download page.
  7. Calls verify_channel_links — fails the build if any channel link is dangling.

Then ./deploy --releases rsyncs dist/release-output//srv/zddc/releases/ with --delete-after.

Channels

Three release channels, applied in lockstep across all seven tools (6 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.

  • Stable — versioned, immutable. ./build release [version] writes per-version HTML for the six HTML tools and per-version binaries for zddc-server (real bytes), refreshes the symlink chain (5 symlinks per HTML tool + 5 symlinks per zddc-server platform) all → the new version, and tags <tool>-v<X.Y.Z> for every tool. Skips per-tool HTML rewrites when source hasn't changed since that tool's last stable tag (binaries always rebuild).
  • Beta./build beta overwrites <tool>_beta.html for each HTML tool and zddc-server_beta_<platform> for each platform with fresh bytes. Cascades alpha → beta for both HTML and binaries (one symlink per platform). No tag — channel URLs are stable URLs by design.
  • Alpha./build overwrites only the alpha mirrors, all seven tools. No tag, no other side-effects.

A plain ./build (no arg) is a dev build: it produces dist/<tool>.html and zddc/dist/zddc-server-<platform> binaries; doesn't touch dist/release-output/ or the live site. The download index, stub pages, and verifier only run when a channel/release is being cut.

The cascade rule (stable cut → beta + alpha mirrors reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale across either HTML or binaries. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release.

The on-page {{BUILD_LABEL}} is rendered red+bold for dev/alpha/beta builds (is_red=1) and black for stable releases. The label format is:

Build Label
dev (no --release) v0.0.6-alpha · 2026-04-27 14:00:00 · abc1234[-dirty]
--release alpha v0.0.6-alpha · 2026-04-27 · abc1234
--release beta v0.0.6-beta · 2026-04-27 · abc1234
--release [ver] v0.0.5

X.Y.Z for non-stable labels is the next-stable target — patch+1 from the latest clean <tool>-vX.Y.Z tag. Dev builds use the full timestamp + -dirty marker so iterative work is distinguishable from a formal --release alpha cut (which stamps date-only and is committed-clean by definition).

Install distribution model

Two orthogonal axes: how the bytes get there (this section), and what runtime mode the tool ends up in (next section).

Local mode (no server): download a tool .html from https://zddc.varasys.io/releases/ and open it. Tools are self-contained — no install, no install script.

Server mode (zddc-server): the binary //go:embeds the current-stable build of every tool at compile time. That's the default — no fetching happens out of the box. The server virtually serves each tool only at directories where the convention says it belongs (internal/apps/availability.go):

App Available at
archive every directory (multi-project, project, archive, vendor)
classifier any Incoming, Working, or Staging directory and its subtree
mdedit any Working directory and its subtree
transmittal any Staging directory and its subtree
landing only at the deployment root

Resolution order at a request to <dir>/<app>.html where the app is available:

  1. Override — real .html file at the path → static handler.
  2. .zddc apps: cascade — walk leaf→root for an apps.<app> entry. Spec is stable/beta/alpha (canonical channel), v0.0.4/v0.0/v0 (canonical version), full URL (custom mirror), or local path. Closer-to-leaf wins.
  3. Embedded — the build-time HTML compiled into the binary.

URL sources fetch once on first request and cache forever in <ZDDC_ROOT>/_app/<host>/<path>. There is no background refresh, no SHA-256 verification, no admin UI. To pull a new build, delete the cache file. Concurrent misses for the same URL share one outbound fetch (hand-rolled singleflight). Failed fetches fall through to embedded with a one-time WARN log per source URL. Direct URL access to /_app/... is blocked at dispatch.

The X-ZDDC-Source response header always reports what was served: fetch:URL, cache:URL, path:/abs, or embedded:<app>@<build>.

Runtime mode detection

Independent of how the tool got installed. archive auto-detects from the URL and folder shape (?projects= set → multi-project; scan root has an archive/ child → project-root; otherwise → in-archive). The other tools don't care — transmittal, classifier, mdedit work the same regardless of where they live.

Build Script Requirements

Every build.sh must:

  • Begin with #!/bin/sh and set -eu (POSIX sh, not bash)
  • Source shared/build-lib.sh first (provides ensure_exists, concat_files, build_timestamp, compute_build_label, promote_release, plus the lockstep helpers _coordinated_next_stable, promote_zddc_server, write_zddc_server_stubs_all, verify_channel_links)
  • Fail immediately on missing source files (ensure_exists pattern)
  • Clean up temp files on exit (use trap cleanup EXIT)
  • Accept --release [<version>|alpha|beta] — explicit version or channel name; otherwise produce a dev build

HTML Embedding Safety

When inlining JavaScript into a <script> block, the HTML parser scans for the exact string </script> to terminate the block — backslash escaping (<\/script>) does not prevent termination. Any JS source file or vendor library that contains </tag> sequences inside string literals or template literals will break the inline <script> block.

The rule is:

All </ sequences in inlined JavaScript must be escaped as <\/ using sed.

Both the app JS concatenation step and any vendor JS bundling step must run through:

sed 's#</#<\\/#g' "$input_js" > "$safe_js"

Then use </script> (not <\/script>) to close the <script> block, since the content no longer contains any </ sequences that the parser could misread.

This is already enforced for mdedit's vendor bundling. It is the contributor's responsibility to ensure new tools follow this pattern.

Vendor Dependencies

Some tools bundle third-party libraries. These live in tool/vendor/ and are committed to the repository. The build script inlines them into dist/tool.html.

Current vendor files:

Tool Library File Notes
mdedit Toast UI Editor v3.2.2 vendor/toastui-editor-all.min.js Markdown editor with live preview
mdedit Toast UI Editor CSS vendor/toastui-editor.min.css Editor stylesheet
transmittal jszip, docx-preview, xlsx CDN at runtime Optional preview features; tool works without them

Runtime CDN loading exception: The transmittal tool loads jszip, docx-preview, and xlsx from CDN at runtime via loadLibrary() forDOCX/XLSX preview functionality. These are optional enhancements—core transmittal functionality (JSON payload communication) works without them. This exception is documented here because:

  1. The core transmittal features (creating, signing, verifying SHA-256 digests) do not depend on these libraries
  2. Preview functionality gracefully degrades if libraries fail to load
  3. Bundling would significantly increase file size for rarely-used features

Rule: Runtime CDN loading is allowed only when:

  • Features are strictly optional (graceful degradation)
  • Core functionality works without the external library
  • Library is clearly documented as non-essential

template.html for tools with vendor deps loads those deps from CDN for convenient local development. The build script replaces CDN tags with the bundled vendor files in the output.

Development vs Production

Context Tailwind / Vendor How to run
Development CDN (live, from template.html) Open template.html directly in Chromium
Production Bundled / Static CSS Run bash tool/build.sh, open dist/tool.html

For mdedit specifically: template.html loads Toast UI from CDN and uses Tailwind Play CDN. The build replaces Toast UI with the bundled vendor file and replaces the Tailwind CDN script with the static css/tailwind-utils.css subset.


JavaScript Architecture

Vanilla JS Only

All tools use plain JavaScript — no TypeScript, no frameworks, no bundlers. Dependencies are managed manually via vendor files.

Module Pattern

Each JS file wraps its code in an IIFE or module-scope block and registers its API on window.app.modules:

// js/mymodule.js
(function() {
    function doSomething() { ... }

    window.app.modules.mymodule = { doSomething };
})();

Two top-level globals:

  • window.app — per-tool app state, modules, and debug surface (every tool)
  • window.zddc — shared filename/folder/revision parsers from shared/zddc.js (every tool)

No other globals. Never expose implementation internals beyond what's needed for testing.

Module Load Order

JS files are concatenated in the order declared in build.sh. Each file can assume earlier files' modules are available on window.app. Circular dependencies are not permitted — modules must be layered.

Typical ordering:

app.js          ← Declares window.app and top-level state
utils.js        ← Stateless helpers (no dependencies)
store.js        ← State management (depends on app.js)
[domain].js     ← Feature modules (depend on store/utils)
main.js         ← Initialization (depends on all modules)

State Management

Tools manage state in one of two patterns:

1. Direct state on window.app (archive, classifier, mdedit)

window.app = { files: [], selectedFolders: new Set(), modules: {}, ... };

State is read directly; mutations trigger explicit re-render calls. Classifier additionally layers a small pub-sub on top via store.js (store.on('files', render)).

2. Proxy-based reactive state (transmittal)

const state = createReactiveState({ mode: 'edit', published: false });
state.subscribe((prop, newVal) => { /* auto-update UI */ });
state.mode = 'view'; // Proxy notifies all subscribers automatically

Use reactive state when the same property drives multiple independent UI elements. Use direct state when the data flow is simple and unidirectional.


Tool-Specific Architecture

Archive Browser

Pattern: Direct mutation of window.app.{directories, files, filteredFiles, selectedFiles, ...}, helper modules namespaced under window.app.modules.{events, table, urlState, source, ...}. Supports two source modes (window.app.sourceMode): 'local' (File System Access API) and 'http' (zddc-server JSON browse).

Two-level directory structure required:

root-directory/
  transmittal-folder/         ← "grouping folder" — must be a subdirectory
    123456-EL-SPC-0001_A (IFC) - Spec.pdf
    ...

Files at the root level are ignored. The grouping folder list and transmittal folder list are populated from the first two levels of the selected directory. Files are only counted in filteredFiles after ZDDC filename parsing succeeds.

Key DOM IDs: #addDirectoryBtn, #noDirectoryMessage, .main-container, #filesTableBody, #fileCount, #selectedCount, #selectAllGroupingCheckbox.


Document Classifier

Pattern: Event-driven store (store.js) with notify() / on() pub-sub, spreadsheet rendering on 'files' events.

File object shape (as produced by scanner.js):

{
    trackingNumber: '123456-EL-SPC-2623',
    title: 'Specification',
    revision: 'A',
    status: 'IFC',
    extension: 'pdf',                 // no leading dot
    originalFilename: '...',          // filename without extension
    name: '...',                      // full filename with extension
    path: 'folder/filename.pdf',
    size: 45000,
    isDirectory: false,
    manualFilename: null              // set if user overrides computed name
}

computeNewFilename(file) (in utils.js) returns file.originalFilename + '.' + file.extension if any required field is missing.

Main app panel (#mainApp) stays hidden (class hidden) until a real directory is opened via showDirectoryPicker. State can be injected via store.setFolderTree() + store.setSelectedFolders() for testing without triggering the picker.


Markdown Editor (mdedit)

Pattern: Global functions (window.updateToc), editor instances managed per file-path in a Map, File System Access API for direct file read/write.

Dependencies: Toast UI Editor v3.2.2 (bundled), Tailwind utility subset (static CSS).

Toast UI availability check:

if (typeof toastui === 'undefined') {
    // Graceful degradation — show error message
}
const editor = new toastui.Editor({ el: container, ... });

Key DOM IDs: #app, #select-directory, #welcome-screen, #file-tree, #content-container.

File tree: Populated after showDirectoryPicker() resolves. File items are rendered as DOM children of #file-tree. Clicking a file opens it in the editor panel.


Transmittal Creator

Pattern: Proxy-based reactive state, two-phase hydration, ECDSA digital signatures, SHA-256 file integrity.

Two-phase hydration:

  1. populateStatic() — called before publishing. Fills all form fields and the file table into the HTML so the output is readable without JavaScript (progressive enhancement for SharePoint, email clients, etc.).
  2. hydrate() — called on page load of a published transmittal. Hides the "Not Validated" static warning, runs signature verification, and enables interactive features.

Progressive enhancement matrix:

Feature No JavaScript With JavaScript
Content display Full Full
File table Shown Shown
Digest / signatures Listed Listed + cryptographically verified
Validation status ⚠️ "Not Validated" badge "Verified" / "Invalid"
Editing Disabled Enabled (if draft)
Column filtering No Yes

Data store: A <script id="transmittal-data" type="application/json"> element embedded in the published HTML holds the full transmittal payload. On load, data.js reads and parses it; all UI state derives from this JSON.

Reactive state:

// app.state is a Proxy — assignments auto-notify subscribers
app.state.mode = 'view';  // Triggers UI updates automatically

Subscribe for cross-cutting concerns:

app.state.subscribe((property, newValue) => {
    if (property === 'mode') updateModeToggleLabel(newValue);
});

Security model: ECDSA P-256 signing of the SHA-256 digest. Signatures are stored in the JSON payload. Any number of signers can co-sign. Verification runs client-side in the browser's Web Crypto API — no server required.

Key module globals: window.transmittalApp exposes app.data, app.state, and app.modules for debugging and testing.


Form Renderer (form/)

Pattern: Schema-driven renderer for the form-data system. Reads a JSON Schema 2020-12 + RJSF-style ui:* hints from a server-injected <script id="form-context"> block; recursively walks the schema and mounts a tree of widgets; on submit, walks the widget tree to serialize back to JSON and POSTs to the URL the form was loaded from.

Why schema-driven (vs. transmittal's hardcoded HTML): the form tool is generic — one renderer serves any form spec a user (or LLM) drops into the file tree. Adding a new form requires no code change; adding a new field type to an existing form requires only a YAML edit.

Widget interface — every widget exposes:

  • el — DOM root
  • read() — current value (recurses into children for object / array)
  • setError(msg) / clearErrors() — show / clear field-level errors
  • child(name|idx) — for container widgets, look up nested widget by JSON-Pointer segment (used by errors.js to attach server-side validation messages by path)

Module layout:

  • js/app.jswindow.formApp = { context, rootWidget, modules }
  • js/context.js — read injected #form-context JSON
  • js/util.jsh() DOM builder, JSON-Pointer encode/parse
  • js/widgets.js — primitives (string/number/integer/boolean/enum, format date/email, textarea)
  • js/object.js — fieldset rendering with ui:order resolution
  • js/array.js — repeating-row UX (add/remove)
  • js/render.js — type-triage dispatcher
  • js/serialize.js — read tree → JSON
  • js/errors.js — distribute errors by JSON Pointer path
  • js/post.js — POST + handle 200/201/422/403/409 responses
  • js/main.js — boot: load context, mount root widget, wire submit

Server-side counterpart: zddc/internal/handler/formhandler.go recognizes *.form.html and *.yaml.html URLs, parses the spec, validates submissions via zddc/internal/jsonschema/, writes via zddc.WriteAtomic. Existence of <name>.form.yaml is the trigger; without it, the URL falls through to static-file serving.

Round-trip philosophy: v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like .zddc itself where users hand-edit and comments must survive.


Server security model

zddc-server is the deployable surface — it gates access to the file tree that all the HTML tools work against. The trust story is intentionally narrow, delegated, and small enough to audit. This section frames it; operator detail (syntax, anti-patterns, worked layouts, verification recipe, federal-readiness gap analysis) lives in zddc/README.md § "Access control: the .zddc cascade."

Cooperating layers

Six layers cooperate to bound what a request can reach. Each does one job; none of them is load-bearing alone.

Layer Job Implementation
Authentication Establish caller identity (email) Two paths: Authorization: Bearer <token> validated against <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex> (CLI / scripted callers); or X-Auth-Request-Email injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required
Policy decider Yield an allow/deny verdict for (identity, path, chain) Pluggable via ZDDC_OPA_URL: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. zddc/internal/policy/
ACL cascade The default decider's rule set Per-directory .zddc YAML with verb-set permissions (r/w/c/d/a) and roles, walked deepest-first first-match-wins under --cascade-mode=delegated or with absolute ancestor denies under --cascade-mode=strict (zddc/internal/zddc/acl.go, cascade.go). External OPA can replace this rule set with arbitrary Rego while keeping the same .zddc files as input data
Special folders Codify the bilateral exchange-record archetype Incoming/Working/Staging get auto-ownership on mkdir (creator gets rwcda via an auto-written .zddc); Issued/Received enforce a server-side WORM split (ancestor grants masked to r; only an explicit .zddc at-or-below the WORM folder can grant c for a write-once drop-box). Admins exempt. zddc/internal/zddc/special.go
Tool-rooted view Make the caller's accessible subtree feel like their entire world (UX containment) Archive auto-served at every directory; the URL it's served at is its root. No breadcrumb leads above
URL canonicalization Resolve URL paths to on-disk casing before any layer below sees them zddc/internal/fs/resolve.go ResolveCanonical — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (.archive, .profile, .tokens) flow through verbatim
Reserved hidden prefixes Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch .-prefixed → 404 + listing-filtered; _-prefixed → listing-filtered only
Audit log Reconstruct who did what after the fact JSON-line tee per request to <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log; writes also emit file_write op records
File API Authenticated CRUD over the served tree zddc/internal/handler/fileapi.go — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (r/w/c/d/a). Mkdir under Incoming/Working/Staging writes a creator-owned .zddc automatically

Master + proxy / cache / mirror

The same zddc-server binary runs in two distinct topologies:

  • Master mode (default): the binary owns a file tree under --root, applies .zddc ACL cascades to incoming requests, serves files / virtual app HTML / archive listings / form submissions / table views. The "normal" zddc-server. All of cmd/zddc-server/main.go lives here.
  • Client mode (--upstream <url> set): the binary becomes a downstream proxy/cache/mirror against another zddc-server. The master-side machinery (archive index, apps server, watcher, OPA decider, ACL middleware, token store) is bypassed entirely. zddc/internal/cache/ is the entire request handler.

Three sub-modes within client mode, controlled by --mode <proxy|cache|mirror>:

Mode Persists responses? Subtree warmer? Use case
proxy no no thin pass-through; nothing on local disk
cache (default) yes no field engineer — what you've viewed is available offline
mirror yes yes (access-triggered, subtree-scoped) vendor mirrors of their subtree; admin backups; complete offline working set

Internally the modes collapse to two switches on a single request-handling pipeline (persist, warm). Proxy is cache without disk writes; mirror is cache plus an access-triggered walker. Implementation factor: cache.New reads cfg.Mode once and sets c.persist = mode != "proxy"; the warmer is the only path that doesn't yet exist (phase 3).

Mirror scope falls out of auth. Whatever the client's bearer can see at upstream is what the cache can populate. Admin's bearer → mirror gets everything (full backup). Vendor's bearer → mirror is exactly that vendor's permitted subtree. No code distinguishes admin-vs-user — master-side ACL filtering does it.

Cache directory IS a normal ZDDC root

The cache directory layout is intentionally a regular ZDDC root: <master>/foo/bar.txt is stored at <root>/foo/bar.txt. No sidecar metadata files. The local file's mtime is set to the upstream's Last-Modified header (so revalidation via If-Modified-Since reflects the master's notion of file age, not local fetch time). A small .zddc-upstream marker file at the root records the upstream URL and first-cached-at timestamp, written once by sync.Once on first persist.

Two consequences:

  • zddc-server --root <cache-dir> (without --upstream) serves whatever's been cached as a plain master. Useful for portable offline snapshots — tar the directory, hand it to a colleague, they have a working ZDDC.
  • The master/client boundary is one flag: setting/unsetting --upstream switches behavior on the same on-disk root.

Pipeline

Phase 2 ships GET/HEAD only; writes are deferred to a later phase. For each incoming request:

  1. Directory request (URL ends in /): always proxied live. Listing-cache support belongs with the mirror walker (phase 3) — the bare cache directory's contents only reflect visited files, so a local-walk listing would be misleading.
  2. File request, cache hit (persist mode): serve cached bytes via http.ServeContent (which handles Range natively + 304 conditional GETs). Header X-ZDDC-Cache: hit. Background goroutine fires an If-Modified-Since revalidate; on 304 no-op, on 200 overwrite the cache atomically, on 403/404 purge.
  3. File request, cache miss: build an upstream request preserving Range, If-Range, Accept, Accept-Encoding; attach the configured bearer. Stream the response simultaneously to the client AND to a tmp file in the cache directory; rename atomically only on success. Header X-ZDDC-Cache: miss.
  4. Proxy mode (no persist): same as miss but skip the tmp-file teeing. Header X-ZDDC-Cache: proxy.
  5. Network error + cached version exists: serve the cached bytes with X-ZDDC-Cache: offline. (When the cache hits before any network attempt, the header is hit — there's no way to distinguish "hit while online" from "hit while offline" without an extra round-trip; the header tells the user "this is from disk," and the user infers freshness from context or a future explicit freshness probe.)
  6. Network error + no cached version: 503 Service Unavailable + X-ZDDC-Cache: offline.

Responses with Cache-Control: no-store or Cache-Control: private pass through but are not persisted. Non-200 responses (including 206 partial content) are forwarded but not persisted — caching a partial body would corrupt subsequent full-body reads.

Hop-by-hop headers per RFC 7230 §6.1 (Connection, Keep-Alive, Transfer-Encoding, etc.) are dropped from forwarded responses; Go's transport drops most automatically, but the cache layer adds a guard for the cases that slip through.

Mirror walker (access-triggered)

--mode mirror adds an access-triggered subtree warmer (zddc/internal/cache/walker.go) on top of the cache pipeline. Naive design ("walk on a fixed timer") would scale poorly: many vendor mirrors against one master would generate thundering-herd polls of subtrees no human has looked at in months. Instead, walks are demand-triggered, rate-limited per-subtree.

Trigger policy (MirrorScheduler.Trigger(urlPath) is installed as the cache layer's onAccess hook, called in a goroutine on every authenticated request):

  1. Match urlPath against the configured --mirror-subtrees. Longest prefix wins; / is a catch-all (full mirror).
  2. If a walk is already in flight for that subtree, no-op.
  3. If now - last_walk_at < --mirror-min-interval (default 1h), no-op.
  4. Otherwise, mark in-flight and kick a walk goroutine.

Walk:

  1. Recursively fetch JSON listings under the subtree, persisting each as <dir>/.zddc-listing.json (so directory browsing works offline for walked subtrees).
  2. For each file, fire a conditional If-Modified-Since GET (bounded parallelism — default 4 concurrent, configurable). 304 = no-op; 200 = overwrite; 403/404 = purge.
  3. Per-directory orphan purge: any local file present locally but absent from the upstream listing is removed (handles upstream deletes + ACL revocations).

State persists at <root>/.zddc-mirror-state.json as {subtrees: {<path>: {last_walk_at}}}. In-flight tracking is in-memory only — a crash mid-walk lets the next access retry without manual cleanup.

Properties:

  • Idle mirrors are quiet. No requests means no walks means zero upstream traffic.
  • Active mirrors stay current as a side effect of normal use (no explicit refresh gesture).
  • Revocation latency is bounded by access frequency. Documented behavior, not a guarantee.
  • Bounded concurrency keeps walks from starving the user's interactive requests on the same connection pool.

Writes: outbox + offline replay

PUT / POST / DELETE are handled by cache.handleWrite. Online: forwarded to upstream; on success the cached entry for the path (if any) is dropped so the next read fetches fresh. PUT/DELETE include If-Unmodified-Since from the cached file's mtime — the master returns 412 Precondition Failed if its file changed since the cache observed it, so concurrent writes can't silently clobber.

When upstream is unreachable, the request is captured in the outbox (zddc/internal/cache/outbox.go) under <root>/.zddc-outbox/<id>/meta.json (method, raw URI, content-type, base mtime, queued-at) + body.bin (request body, capped at MaxOutboxBodyBytes = 256 MiB). The client gets back 202 Accepted + X-ZDDC-Cache: queued and a JSON envelope referencing the queued entry.

A background RunReplayLoop started by runClient in main.go replays in queue order:

  • 2xx → entry deleted; cached entry for the path (if any) dropped so the next read fetches fresh.
  • 412 → entry renamed to <id>.conflict-<RFC3339>/. The conflict directory keeps both meta.json and body.bin intact for manual reconciliation.
  • 4xx other than 412 → entry dropped (won't succeed on retry; logged at WARN).
  • 5xx / transport error → left in place for the next pass.

Replay schedule: an eager pass at startup, then 30s while pending, 5min while idle. Honors graceful-shutdown context cancellation. Disabled in --mode=proxy (proxy mode persists nothing by design — offline writes just return 503).

ID encoding (<unix-nano-base16>-<hex-random>) is lex-sortable so directory iteration replays in queue order without an explicit index. MarkConflict appends .conflict-<ts> to the directory name; if a same-second conflict collides (unlikely), a 4-char random suffix is appended.

The local cache is not updated for offline writes by design — until upstream confirms, the user reads still see the upstream-cached version (or 503 if uncached). Trade-off: the user doesn't see their own offline edits immediately, but no "did the queued write actually win?" ambiguity. Phase 5 will add a conflict-resolution UI that surfaces .conflict-<ts>/ directories alongside the cached files in browse views.

Multi-tenancy: explicitly out of scope (v1)

The local instance forwards a single bearer (loaded from --bearer-file at startup) regardless of who's calling locally. Single-user-trust on a laptop. For multi-user scenarios, run multiple instances on the same host, or front the local server with your own auth proxy that injects per-user bearers downstream — both options keep the cache layer's design surface minimal.

Confused-deputy guard at startup

Because the cache forwards a bearer upstream without authenticating the local caller, exposing the bind on a non-loopback interface would turn the binary into an open-proxy laundering anyone's request through the master. The config layer (zddc/internal/config/config.go) enforces two defenses:

  1. Loopback default in client mode. When --upstream is set, --addr defaults to 127.0.0.1:8443 instead of :8443 — but only when --addr / ZDDC_ADDR was not set explicitly. CLI users on a laptop get safe-by-default; operators who want a non-loopback bind opt in explicitly.
  2. Refuse non-loopback bind + bearer without acknowledgement. A non-loopback --addr with a configured --bearer-file without --insecure-direct (ZDDC_INSECURE_DIRECT=1) refuses to start. The error message names the bind, names the flag to acknowledge, and names the threat (open proxy confused-deputy). The helm zddc-server-cache/ chart sets ZDDC_INSECURE_DIRECT=1 and relies on Kubernetes-namespaced networking for the gating — that path is unaffected. The guard is bearer-file-conditional because proxy mode without a bearer doesn't have a credential to launder, and refusing it would needlessly block proxy-without-auth deployments.

Bearer token issuance

zddc-server issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). The master is the identity provider; no external IDP, no JWKS rotation.

Storage<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex> per token. Filename is the hash of the token, never the plaintext value. File contents are YAML (email, created, expires, description). Mode 0600, directory mode 0700, atomic writes via temp+rename.

Why hash-as-filename: a leak of the tokens directory (backup tools, FS-level audit logs, accidental ls in a screen recording) exposes hashes, not credentials. Same posture as /etc/shadow storing password hashes rather than passwords. The plaintext exists only in transit (HTTP Authorization header) and on the operator's disk (a 0600 file they manage).

Self-service flow:

  1. User signs in via the browser (master's normal upstream auth).
  2. Visits /.tokens — small HTML page (zddc/internal/handler/tokenhandler.go) listing existing tokens and offering a creation form.
  3. JS fetches the JSON API (/.api/tokens), POSTs a new token, displays the plaintext once.
  4. User copies into a 0600 file; passes --bearer-file <path> to a CLI.

API:

Method Path Purpose
GET /.api/tokens list current user's tokens (no plaintext)
POST /.api/tokens create; plaintext returned exactly once
DELETE /.api/tokens/<id> revoke (8-char ID or full 64-char hash)

Validation in the request path: ACLMiddleware in zddc/internal/handler/middleware.go checks Authorization: Bearer … first; on success, sets the request email from the token file and falls through. Any failure (missing / malformed / expired) → 401. There is no silent fallback to anonymous on Bearer failure — a misconfigured client must fail loudly rather than escalate to "no auth at all." When no Bearer is present, the existing cfg.EmailHeader path runs unchanged.

Directory shielding: the tokens path is shielded by the existing .-prefix rules — dispatch() 404s any URL containing a dot-prefixed segment (other than the recognized virtual prefixes), and fs.ListDirectory filters dot entries from listings. The token system relies on this; a regression here is a credentials-leak vector. The token-handler test suite (tokenhandler_test.go) exercises the auth path; verifying the URL-level guard is the responsibility of main_test.go (TestDispatchHidesDotPrefixedSegments).

--no-auth / "this instance is not the ACL boundary"

A symmetric flag, used in two distinct deployment shapes:

  • Master with --no-auth: no ACL enforcement, no auth required. Anyone hitting the port reads everything in scope. Suitable for dev, internal trusted-LAN read-only tooling, or genuinely public archives.
  • Client with --no-auth (downstream proxy/cache/mirror — see "Master + proxy / cache / mirror" below for context): the client trusts upstream's ACL filtering. Whatever the upstream returned is what the client serves; no per-request re-evaluation against .zddc files in the cache directory. Single-user-trust model on a laptop.

Implementation is a single swap: policy.AllowAllDecider{} replaces the configured decider when cfg.NoAuth is true. All existing handlers continue to call policy.AllowFromChain (or equivalent) unchanged; they just always get allowed=true. Logged at WARN on every restart so operators who set the flag inadvertently see it on stderr.

Distinct from --insecure, which only relaxes a startup-time safety check (refuse to start when no root .zddc exists). The two flags are independent.

Commercial vs federal trust model

The current implementation is well-shaped for a commercial-tenant model with delegated auth. Federal-grade qualification (FedRAMP Moderate, NIST 800-53, FIPS 140-3, DoD STIG) requires several layers to harden. Operators deciding whether to deploy the system should know which column they're in.

Property Commercial trust model (current) Federal trust model (gaps to close)
Identity Email from upstream proxy header mTLS or signed forwarding token; PIV/CAC via IdP
Cryptography Go stdlib defaults FIPS 140-3 validated module (microsoft/go or RHEL FIPS)
TLS Go stdlib defaults Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS
Access model Per-verb (r/w/c/d/a) with first-class roles and an admin escape hatch — closes NIST AC-3(7) (closed by default; external Rego still available for org-specific policy via ZDDC_OPA_URL)
Subtree authority Operator-toggled cascade mode: delegated (default — leaf grants override ancestor denies) or strict (--cascade-mode=strict — ancestor explicit-denies are absolute, NIST AC-6) (closed; strict is the federal posture)
Audit log integrity Local lumberjack rotation, filesystem-trusted Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive
Information disclosure Anonymous reaches / and /.profile (project picker, public-projects names) All endpoints behind authenticated proxy; no anonymous discovery
Apps URL fetches Fetch-once-cached, no integrity check SHA-256 pin + signature verification
Disclosure process Not formalized SECURITY.md, embargoed-fix workflow, CVE assignment

The full bullet list with NIST control references is in zddc/README.md § "Federal-readiness gap analysis."

Permission model: roles + verbs

Five permission verbs gate every read and write:

Verb Allows
r read file bytes; list directory
w overwrite an existing file; rename existing file
c create a new file or directory
d delete a file
a modify the ACL of this subtree (write .zddc)

.zddc files express grants under acl.permissions: { principal → verb-set }. A principal containing @ is an email pattern matched by MatchesPattern (existing glob); a bare name is a role looked up against roles: definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny. Legacy acl.allow / acl.deny lists fold into permissions at parse time (allowrwcd, deny""), so existing deployments behave identically.

Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. Operators select the precedence model for ancestor denies via --cascade-mode:

  • delegated (default) — historical commercial behavior; a leaf allow overrides an ancestor explicit-deny.
  • strict — NIST AC-6 posture; an ancestor explicit-deny is absolute and cannot be overridden by any leaf grant.

The admins: field in the root .zddc and any subtree .zddc remains the bypass: root admins (IsAdmin) and subtree admins (IsSubtreeAdmin) get unconditional rwcda and skip both the cascade and the WORM mask.

Special folders

Five folder names drive built-in behaviors (canonical list in zddc/internal/zddc/special.go):

  • Incoming, Working, Staging — auto-ownership on mkdir. The file API's POST X-ZDDC-Op: mkdir writes a .zddc into the new subdirectory granting the creator's email rwcda directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators.
  • Issued, Received — write-once / immutable archive. Server-side WORM split: at any path crossing an Issued or Received segment, ancestor cascade grants are masked to r only; verbs at-or-below the WORM folder retain r,c. To grant cr (drop-box) to a doc controller, the operator places a .zddc at the Issued/Received folder explicitly listing the role. No principal can w/d/a inside the archive — only admins can mutate filed documents.

The user-stated "drop box" archetype is the doc controller's cr set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after.

File API (authenticated CRUD)

zddc-server exposes write methods on the same URL space as GET. Each method maps to a specific verb and is gated against the cascade-derived verb set:

Method URL Headers Action verb Status
PUT /<new-path> If-Match: "<etag>" (optional) c 201 created
PUT /<existing-path> If-Match: "<etag>" (optional) w 200 overwritten
PUT /<dir>/.zddc a 200/201
DELETE /<path> If-Match: "<etag>" (optional) d 204
POST /<path> X-ZDDC-Op: move + X-ZDDC-Destination: /new/path w (src) + c (dst) 200
POST /<path>/ X-ZDDC-Op: mkdir c 201 created / 200 idempotent

Writes use WriteAtomic (temp file → fsync → rename) for partial-write safety. Move uses os.Rename for same-FS atomicity. Body size capped by --max-write-bytes (default 256 MiB). Reserved hidden segments (.-prefixed, _app, _template) are 404'd uniformly with the read path. Every write logs a structured file_write event (op, path, email, status, bytes) into the same audit stream as access logs.

Browser clients reach the API through shared/zddc-source.js — an FS Access API polyfill (HttpDirectoryHandle, HttpFileHandle) that lets tools written against showDirectoryPicker() work unchanged when served by zddc-server. classifier, mdedit, and transmittal auto-detect HTTP mode at startup, build a polyfill handle for location.pathname's directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.

Why the tool-rooted view matters for third-party containment

A vendor given access to /Archive/Acme/Incoming/ lands at the archive tool served at that URL, with that subtree as its world. There is no breadcrumb to /Archive/, no "go to root" button, no listing of sibling vendor folders. This is UX containment — it makes the deployment feel self-contained and prevents accidental discovery of out-of-scope paths. It is not the security mechanism. Even if a vendor hand-crafts a URL to /Archive/ or /, the cascade's default-deny rejects them before any byte is served. The tool-rooted view exists so a vendor reading their email link doesn't have to choose between "click something I shouldn't" and "click nothing"; the cascade exists so that choice never matters.

This pairing — UI affordance contained, ACL enforced — is why the server auto-serves archive.html at every directory under ZDDC_ROOT. If the archive tool only worked at root, every locked-down subtree would need either a hand-crafted entrypoint or a tool that knew how to scope itself. Auto-serving makes "vendor's world starts at /Archive/<their-name>/" the out-of-the-box behavior with no per-deployment configuration.


CSS Architecture

All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).

Common conventions:

  • CSS variables for theme colors and spacing in base.css
  • Component-scoped class names (no global utilities except where Tailwind provides them)
  • .hidden class uses display: none !important for JavaScript show/hide
  • Print styles in a separate print.css

mdedit Tailwind subset:

css/tailwind-utils.css contains only the ~80 Tailwind v3 utility classes actually used in template.html. If a new utility class is needed in the template, add it here. Classes follow Tailwind v3 naming and values exactly.


Testing

Tests use Playwright with Chromium only (File System Access API requires it).

Running Tests

npm test                    # all tools
npx playwright test archive # single tool
npx playwright test --debug # debug mode

Test Structure

Each tool has a spec file in tests/:

tests/
  archive.spec.js      ← 2 tests: load + directory scan
  classifier.spec.js   ← 2 tests: load + store injection
  mdedit.spec.js       ← 2 tests: load + file tree render
  transmittal.spec.js  ← 2 tests: paste round-trip + filesystem round-trip
  fixtures/
    mock-fs-api.js     ← Reusable File System Access API mock
    transmittal-data.js
    zddc-filenames.js

Mock File System API

MOCK_FS_INIT_SCRIPT (from tests/fixtures/mock-fs-api.js) overrides showDirectoryPicker, showOpenFilePicker, and showSaveFilePicker. Inject it via page.addInitScript before navigating.

// Flat directory
window.__setMockDirectory('name', [{ name: 'file.pdf', content: '...', size: 100 }]);

// Nested directory tree
window.__setMockDirectoryTree('name', {
    'subfolder': { 'file.pdf': 'content' },
    'root-file.md': 'content',
});

Writing Tests

Follow the pattern in tests/transmittal.spec.js:

  • Use ESM import syntax
  • Inject MOCK_FS_INIT_SCRIPT in test.beforeEach for any test that navigates to a tool page
  • Use waitUntil: 'domcontentloaded' or 'load' (not 'networkidle' — the bundled scripts may keep the network active)
  • Prefer page.waitForFunction over page.waitForSelector for app-state readiness
  • Assert through the store/module API for tests that don't need visible DOM

Code Standards

Rule Rationale
No </script> or any </tag> in JS string literals Breaks inline HTML embedding — escape with '<' + '/tag>' or use <\/ in sed at build time
No external dependencies at runtime Self-contained output requirement
No TypeScript, no bundlers Keeps the build system auditable and simple
Only window.app and window.zddc are global Keeps the global namespace clean; expose only what's needed for debugging
Defensive input validation File System API handles and user-pasted data are untrusted
Update README.md when features ship Documentation parity is a delivery requirement, not optional

Git Workflow

Branching: short-lived feature branches (feature/<name>, bugfix/<name>, hotfix/<name>), squash-merged to main and immediately deleted. Quick fixes (typos, one-liners) go direct to main.

Commit messages: Conventional Commits — <type>(<scope>): <description>. Types: feat, fix, docs, style, refactor, perf, test, chore. See AGENTS.md for the full table and examples.

Releases: Tag the commit after confirming dist/ is current. Format: {project}-v{version} (e.g. archive-v1.0.0). Semantic versioning applies. There is no CI/CD — the built .html file is already committed to the repo.

bash tool/build.sh                  # rebuild dist/
git add -f tool/dist/tool.html      # stage if needed
git commit -m "chore(tool): rebuild for vX.Y.Z"
git tag tool-vX.Y.Z
git push origin main --tags

git tag -l "archive-v*"             # list releases
git push origin :refs/tags/tag-name # delete a remote tag

Adding a New Tool

  1. Create tool/ with the standard directory layout
  2. Write template.html with {{CSS_PLACEHOLDER}} and {{JS_PLACEHOLDER}} markers
  3. Write tool/build.sh following the pattern of an existing tool
  4. Add bash "$SCRIPT_DIR/tool/build.sh" to the root build.sh
  5. Add a test project entry to playwright.config.js
  6. Create a stub tests/tool.spec.js
  7. Force-add the dist output: git add -f tool/dist/tool.html

If the tool requires vendor dependencies, download them to tool/vendor/, add them to .gitignore exclusions if appropriate, and update build.sh to inline them (with the </ escaping step).