diff options
| author | Adam Malczewski <[email protected]> | 2026-06-06 22:08:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-06 22:08:16 +0900 |
| commit | e1c8cf3257cb33457aa882c548f5195ecc0f9854 (patch) | |
| tree | d355147cdab8eb77917ad02caedf26b3d8d0be57 /.dispatch | |
| download | dispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.tar.gz dispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.zip | |
Slice 1: surface system + WS transport + composition root
Pure-core feature libraries assembled at the composition root:
- core/protocol: pure reducer over surface catalog/spec/error messages
- features/surface-host: generic field-kind interpreter (toggle/progress/
selector/stat/button) + pure plan logic; no surface-id special-casing
- adapters/ws: injected WebSocket client (effects at the edge)
- app: composition root store (Svelte 5 runes over the pure reducer),
host-relative surface WS URL resolution (resolveWsUrl), root App.svelte
Verified green: svelte-check 0/0, vitest 84 passed, biome clean, vite build ok.
Diffstat (limited to '.dispatch')
| -rw-r--r-- | .dispatch/package-agent.md | 69 | ||||
| -rw-r--r-- | .dispatch/rules/frontend-inject-transport.md | 7 | ||||
| -rw-r--r-- | .dispatch/rules/frontend-interpreter-generic.md | 8 | ||||
| -rw-r--r-- | .dispatch/rules/frontend-no-ambient-state.md | 7 | ||||
| -rw-r--r-- | .dispatch/rules/frontend-pure-core.md | 7 | ||||
| -rw-r--r-- | .dispatch/ui-contract.reference.md | 169 |
6 files changed, 267 insertions, 0 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md new file mode 100644 index 0000000..a5666e4 --- /dev/null +++ b/.dispatch/package-agent.md @@ -0,0 +1,69 @@ +<!-- ORCHESTRATOR-ONLY meta (see ORCHESTRATOR.md): every FE summon is assembled as + package-agent.md + the inlined .dispatch/rules/* + the per-summon TASK block. + This is the base for ALL FE owner-agents; nothing here is restated per summon. --> + +# Frontend Owner-Agent — Brief + +You are the **sole owner-agent for exactly ONE unit** — a single feature module / +directory under `src/`. Your unit + job are in the **TASK** at the end. You build +it, test it, and write a report — nothing else. If no single unit is named, stop. + +## Hard guardrails (NON-NEGOTIABLE) +- **Single-writer, directory-scoped.** Read/create/edit any file inside your unit's + directory. Never create or edit anything OUTSIDE it — not another feature, not + `src/app/` (the composition root), not root config (`package.json`, + `tsconfig.json`, `vite.config.ts`, `biome.json`), not the harness, not the + backend repo (`../arch-rewrite`). +- **Need a change outside your unit?** Do NOT make it — write a CHANGE-REQUEST in + your report (a sibling's public export, a backend contract, root config, + composition wiring). The orchestrator dispatches it. +- **No workspace/dep wiring.** Don't `bun install` or edit root config; list a new + dep / wiring need as a CR. +- **No git** (no commits, branches, pushes, resets). + +## What you may read (visibility) +- **Your own unit:** every file, freely. +- **The contract you consume:** reproduced IN-REPO at + `.dispatch/ui-contract.reference.md` — read THAT. Your code imports + `@dispatch/ui-contract` normally, but **do NOT read `node_modules/@dispatch/*`** — it + symlinks to the backend repo (OUTSIDE this repo) and a headless permission prompt will + HANG the run (see "Headless read boundary"). +- **Sibling units — PUBLIC SURFACE only:** their `index.ts` exports. Don't read + their internals (needing them ⇒ the contract is incomplete → report a CR). + +## Headless read boundary (you run non-interactively) +You run HEADLESS: a Read of any file OUTSIDE this repo (`dispatch-web/`) triggers a +permission prompt that CANNOT be answered → the run HANGS until aborted. Use Read/Edit ONLY +within `dispatch-web/`. If you believe you need a file outside your scope, do NOT attempt +the read — STOP and write the need in your report, then end. + +## Engineering standard (the inlined `.dispatch/rules/*` govern; in brief) +- **Pure core / injected shell.** Decision logic is `input → output`: zero DOM, + zero `fetch`/WS, zero Svelte. Put it in a `.ts` module (e.g. `logic/`) that tests + with NO mounting and NO mocks. Effects are INJECTED (props or an `adapter/`). +- **Svelte-thin.** `.svelte` files wire props/events to pure logic + render; no + business logic. (biome lints `.ts`/`.js` only; `svelte-check` owns `.svelte`.) +- **No ambient state.** Own state explicitly; runes wrap the pure reducer; + subscriptions are disposed on unmount. +- **Tests, asymmetric.** Pure logic → vitest with ZERO internal mocks (never + `vi.mock` of our own modules). Components → a few `@testing-library/svelte` + tests; don't chase coverage there, don't mock siblings. Faking the OUTERMOST + edge (a fake socket/fetch/clock) is the only allowed mock. +- **Isolation over DRY.** Self-contained over a shared helper wired between + features. The only shared surfaces are the imported contracts. +- **Strict TS:** respect `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`. + +## Verify before finishing — YOUR UNIT IN ISOLATION +Run, and paste the output into your report: +- `bunx svelte-check --tsconfig ./tsconfig.json` → 0 errors +- `bunx vitest run src/<your-dir>` → all pass (count goes up) +- `bunx biome check src/<your-dir>` → clean +The orchestrator runs the authoritative full `typecheck`/`test`/`check`/`build`. + +## Report (REQUIRED) → `reports/<your-unit>.md` +1. Files created/changed. 2. Public surface you expose (exported types/functions/ +components). 3. New test names + the isolated-verify output. 4. Change-requests +(sibling export, backend contract, root config, composition wiring) — explicit and +actionable. + +Your specific **TASK** follows at the end of this prompt. diff --git a/.dispatch/rules/frontend-inject-transport.md b/.dispatch/rules/frontend-inject-transport.md new file mode 100644 index 0000000..fbe83d7 --- /dev/null +++ b/.dispatch/rules/frontend-inject-transport.md @@ -0,0 +1,7 @@ +# Rule: inject the transport; parsers are pure + +The WS/NDJSON framing + parsing is a PURE function (bytes/messages → typed events); +the socket/fetch is INJECTED. Test the parser with crafted chunk inputs (and +trace-replay-style fixtures), never a live connection. The op-protocol core is a +pure state machine: `reduce(intent, incoming) → { viewModel, outgoingCommands }`; +the carrier (WebSocket) is the injected shell. diff --git a/.dispatch/rules/frontend-interpreter-generic.md b/.dispatch/rules/frontend-interpreter-generic.md new file mode 100644 index 0000000..557f2d4 --- /dev/null +++ b/.dispatch/rules/frontend-interpreter-generic.md @@ -0,0 +1,8 @@ +# Rule: the surface interpreter is generic + +The surface interpreter switches on field KINDS (toggle/progress/selector/stat/ +button/custom), NEVER on a surface id. An `if (surface.id === "...")` imports a +feature's identity into the platform and breaks isolation (guardrail 1). An +unknown field `kind` or a `custom` `rendererId` with no registered renderer → +GRACEFUL SKIP, never a crash. Render from the spec; the backend owns what a +surface contains. diff --git a/.dispatch/rules/frontend-no-ambient-state.md b/.dispatch/rules/frontend-no-ambient-state.md new file mode 100644 index 0000000..663bf1a --- /dev/null +++ b/.dispatch/rules/frontend-no-ambient-state.md @@ -0,0 +1,7 @@ +# Rule: no ambient state (frontend) + +State is owned per-unit and passed explicitly. NO module-global mutable store +reached from everywhere — that is the old FE's "tools leak across tabs" / +"model resets on tab switch" bug class. Svelte runes (`$state`) are a THIN +reactive wrapper over a pure reducer, never the home of logic. Subscriptions are +owned and disposed on unmount (no orphaned or duplicate subscriptions). diff --git a/.dispatch/rules/frontend-pure-core.md b/.dispatch/rules/frontend-pure-core.md new file mode 100644 index 0000000..fa7bc2e --- /dev/null +++ b/.dispatch/rules/frontend-pure-core.md @@ -0,0 +1,7 @@ +# Rule: pure core / injected shell (frontend) + +Decision logic — reducers, view-models, formatters, parsers — is pure +(input → output): NO DOM, NO `fetch`/WebSocket, NO Svelte import. Put it in a +`.ts` module that tests with zero mounting and zero mocks. Effects (socket, fetch, +IndexedDB, clock) are INJECTED at the edges (props or an adapter). This is for +testability, not purity dogma — stop where it would only add ceremony. diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md new file mode 100644 index 0000000..3962fc1 --- /dev/null +++ b/.dispatch/ui-contract.reference.md @@ -0,0 +1,169 @@ +# `@dispatch/ui-contract` — in-repo reference (read THIS, not node_modules) + +> This MIRRORS the backend's `@dispatch/ui-contract` package source so headless FE agents can +> read the surface contract WITHOUT following the `file:` dep symlink out of this repo (which +> hangs on a permission prompt). Your CODE still imports `@dispatch/ui-contract` normally — this +> file is for READING only. +> +> **Orchestrator:** this is a SNAPSHOT — regenerate it whenever `ui-contract` changes. + +```ts +/** + * UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces". + * + * A SURFACE is a "data transportation surface": a typed description of what data an + * extension exposes, its semantics, and the actions that can act on it — NOT UI. + * Any client renders a surface in its own idiom (web/Svelte, CLI, future TUI/mobile). + * Types-only, zero runtime, zero `@dispatch/*` deps. + */ + +/** Where a surface mounts — a coarse, semantic placement hint, NOT layout/CSS. Open string. */ +export type Region = string; + +/** A typed reference to a backend action a field can invoke (client posts payload back). */ +export interface ActionRef { + readonly actionId: string; +} + +/** One selectable option in a `selector` field. */ +export interface SurfaceOption { + readonly value: string; + readonly label: string; +} + +/** A field within a surface — a SEMANTIC value, not a widget. `kind` is the discriminant. */ +export type SurfaceField = + | ToggleField + | ProgressField + | SelectorField + | StatField + | ButtonField + | CustomField; + +/** A boolean setting plus the action that flips it. */ +export interface ToggleField { + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; +} + +/** A bounded ratio in [0, 1] with a label. Read-only. */ +export interface ProgressField { + readonly kind: "progress"; + readonly label: string; + readonly value: number; +} + +/** An enum choice: the current value, the options, and the action that sets it. */ +export interface SelectorField { + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; +} + +/** A read-only labelled scalar readout. */ +export interface StatField { + readonly kind: "stat"; + readonly label: string; + readonly value: string; +} + +/** A labelled action trigger. */ +export interface ButtonField { + readonly kind: "button"; + readonly label: string; + readonly action: ActionRef; +} + +/** + * The escape hatch: data that fits no semantic field kind. Opaque `payload` + a + * `rendererId`; clients WITH a renderer for that id show it, others GRACEFULLY SKIP. + */ +export interface CustomField { + readonly kind: "custom"; + readonly rendererId: string; + readonly payload: unknown; +} + +/** A surface: an ordered set of fields mounted in a region, with a title. */ +export interface SurfaceSpec { + readonly id: string; + readonly region: Region; + readonly title: string; + readonly fields: readonly SurfaceField[]; +} + +/** A surface-catalog entry — discovery metadata only (no field data). */ +export interface SurfaceCatalogEntry { + readonly id: string; + readonly region: Region; + readonly title: string; +} + +/** The surface catalog: the list of available surfaces a client can choose to show. */ +export type SurfaceCatalog = readonly SurfaceCatalogEntry[]; + +/** A live update for a subscribed surface. v1 carries the full new spec. */ +export interface SurfaceUpdate { + readonly surfaceId: string; + readonly spec: SurfaceSpec; +} + +// ── Surface WebSocket protocol (slice 1: surfaces only) ────────────────────── + +/** A client → server message on the surface channel. */ +export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage; + +export interface SubscribeMessage { + readonly type: "subscribe"; + readonly surfaceId: string; +} + +export interface UnsubscribeMessage { + readonly type: "unsubscribe"; + readonly surfaceId: string; +} + +/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */ +export interface InvokeMessage { + readonly type: "invoke"; + readonly surfaceId: string; + readonly actionId: string; + readonly payload?: unknown; +} + +/** A server → client message on the surface channel. */ +export type SurfaceServerMessage = + | CatalogMessage + | SurfaceMessage + | SurfaceUpdateMessage + | SurfaceErrorMessage; + +/** The current surface catalog (sent on connect and whenever it changes). */ +export interface CatalogMessage { + readonly type: "catalog"; + readonly catalog: SurfaceCatalog; +} + +/** The full current spec for a surface the client just subscribed to. */ +export interface SurfaceMessage { + readonly type: "surface"; + readonly spec: SurfaceSpec; +} + +/** A live update for a subscribed surface. */ +export interface SurfaceUpdateMessage { + readonly type: "update"; + readonly update: SurfaceUpdate; +} + +/** A surface-scoped error. */ +export interface SurfaceErrorMessage { + readonly type: "error"; + readonly surfaceId?: string; + readonly message: string; +} +``` |
