docs(architecture): state-mgmt patterns, zddcMode dispatcher, polyfill gaps

Three additive sections in ARCHITECTURE.md:

1. Promote a recommended state-management pattern. Three patterns coexist
   in the codebase (direct mutation, store pub-sub, Proxy-reactive); the
   recommendation for new tools is direct mutation + explicit re-render —
   it is the boring pick, debuggable, and what 5 of 7 IIFE-pattern tools
   already use. Reactive is appropriate when one state property drives
   ≥3 independent UI regions (transmittal's mode/published/locked).

2. Document the zddcMode dispatcher contract used by the unified
   tables.html bundle that hosts both the form renderer and the table
   view. Standalone form/dist/form.html intentionally has no zddcMode
   set; undefined means "form mode" by back-compat.

3. List zddc-source.js known gaps so callers don't fall into them:
   - recursive directory removal not implemented (HTTP backend has no
     recursive-DELETE endpoint; tools that rename non-empty dirs by
     copy+remove will leak the source dir)
   - no truncate semantics on writes (whole-file replacement only)
   - directory listings re-fetched per traversal (no client-side cache)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 18:48:33 -05:00
parent bf5ea7aa4f
commit 346cbba688

View file

@ -278,17 +278,29 @@ main.js ← Initialization (depends on all modules)
### State Management
Tools manage state in one of two patterns:
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, mdedit, browse, form, tables), and it doesn't hide control flow.
**1. Direct state on `window.app`** (archive, classifier, mdedit)
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
```javascript
window.app = { files: [], selectedFolders: new Set(), modules: {}, ... };
// Mutate then re-render:
window.app.files.push(newFile);
window.app.modules.table.render();
```
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)`).
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, mdedit, browse, form, tables, landing.
**2. Proxy-based reactive state** (transmittal)
**2. Pub-sub store on top of #1** (classifier)
```javascript
store.set('files', newFiles);
store.on('files', render);
```
Adds a tiny `store.on(key, fn)` / `store.notify(key)` layer in `classifier/js/store.js`. Justification: classifier has multiple independent panels (file list, spreadsheet, validation pane) that all need to react to the same state changes; calling three separate `render*()` functions from every mutation site would invite forgetting one.
**3. Proxy-based reactive state** (transmittal)
```javascript
const state = createReactiveState({ mode: 'edit', published: false });
@ -296,7 +308,26 @@ 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.
Used by transmittal because a single state change (e.g. `mode`) drives ≥3 independent UI regions (header chrome, body editability, action toolbar). Reactive shines when the cross-cutting wiring would otherwise be tedious. **Don't reach for this pattern unless you have at least three subscribers per state property.**
### `zddcMode` dispatcher (form / tables unified bundle)
The form and tables tools share a single compiled bundle (`tables/dist/tables.html`, also `//go:embed`d into `zddc-server` at `zddc/internal/handler/tables.html`). One window, two views. The bundle holds both `window.tablesApp` and `window.formApp`; whichever app paints is decided by a single global:
```javascript
// Set by the server-injected context (or absent for standalone form.html):
window.zddcMode = 'form' // → form renderer paints; tables app no-ops
window.zddcMode = 'table' // → tables app paints; form app no-ops
window.zddcMode = undefined // → standalone form.html, treated as 'form'
```
Each app's `main.js` checks `window.zddcMode` first and returns early when it's not their mode (see `form/js/main.js:10`, `tables/js/mode.js`). Rules for adding a third mode:
1. Set `window.zddcMode = '<new>'` in `tables/js/context.js` based on server context shape.
2. Add the new app's main module with the same early-return guard.
3. Keep the standalone-fallback rule consistent: undefined `zddcMode` should still mean "the lightest, most common mode for this bundle's standalone HTML."
Standalone `form/dist/form.html` uses this contract too — it has no `zddcMode` set, so form's main runs unconditionally and renders either the schema (when injected) or a friendly empty-state welcome (`form/js/main.js renderStandaloneWelcome`).
---
@ -671,7 +702,17 @@ zddc-server exposes write methods on the same URL space as GET. Each method maps
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.
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, transmittal, and browse 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.
#### `zddc-source.js` known gaps
The polyfill covers the FS Access surface tools actually use. A few corners are intentionally unimplemented — note them when adding new tool features:
- **Recursive directory removal is not implemented.** `HttpDirectoryHandle.removeEntry(name, { recursive: true })` is a no-op against the server because there is no recursive-DELETE endpoint. Tools that rename a non-empty directory by copy + remove (the FS-Access idiom) will leave the source directory orphaned in HTTP mode. Detect this case and either guard the operation or implement server-side `POST X-ZDDC-Op: move` for the directory.
- **Writes have no truncate semantics.** Each PUT replaces the whole file. There's no `FileSystemWritableFileStream.truncate(size)` analogue; partial-write support means partial-overwrite-via-streaming is the polyfill's only write path.
- **Directory listings are not cached on the client side.** Cache mode does cache file responses (and persists `.zddc-listing.<json|html>` sidecars on the *server* side), but the polyfill itself re-fetches `?json=1` listings on every traversal. Tools that re-enter the same directory many times in quick succession should cache results in tool state.
These are deliberate scope decisions, not bugs. Lift any of them only when a concrete tool feature pays for the implementation cost.
### Why the tool-rooted view matters for third-party containment