From 346cbba6882fac5a32891e111b9c0ffe4bab1998 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 18:48:33 -0500 Subject: [PATCH] docs(architecture): state-mgmt patterns, zddcMode dispatcher, polyfill gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ARCHITECTURE.md | 53 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 896454e..bfdf4b9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 = ''` 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.` 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