Skip to content

Commit 1a3268a

Browse files
committed
docs(dashboard/contract/shell): expanded README + ARCHITECTURE.md
README expanded from quickstart-only to a full developer-facing guide: project structure with per-file purposes, theming overview, embedding explanation, and pointer to ARCHITECTURE.md for the deep dive. ARCHITECTURE.md is the new deep-dive: pipeline diagram, the five core concepts (graph, registry, slots, contributor/parent contexts, bindings), step-by-step walkthrough for authoring a new intent (with real code), guidance on adding shadcn primitives, testing strategy, performance budget, known limitations, and rationale for the major tech choices (shadcn vendored, TanStack Query, Zustand, CSS variables). Adds a .gitignore exception so shell/*.md are tracked alongside the already-tracked design + plan docs.
1 parent d9b707d commit 1a3268a

3 files changed

Lines changed: 334 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ METRICS_DEADLOCK_FIX.md
123123
**/*.md
124124
!**/README.md
125125
!extensions/dashboard/contract/*.md
126+
!extensions/dashboard/contract/shell/*.md
126127
!extensions/dashboard/contract/shell/test/
127128
!extensions/dashboard/contract/shell/test/**
128129
/**/*.disabled
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Architecture: Dashboard Contract Shell
2+
3+
The runtime that turns a Forge dashboard contract YAML into a working React UI.
4+
5+
## Pipeline at a glance
6+
7+
```
8+
┌──────────────┐ POST /api/dashboard/v1 ┌──────────────┐
9+
│ React Router │ ──────────────────────────▶ │ Go contract │
10+
│ PageRoute │ { kind: graph, route } │ registry │
11+
└──────┬───────┘ └──────┬───────┘
12+
│ │ filtered
13+
│ GraphNode tree (typed) │ by user perms
14+
▼ ▼
15+
┌──────────────┐ (server-side)
16+
│GraphRenderer │
17+
│ walks │
18+
└──────┬───────┘
19+
│ for each node
20+
21+
┌──────────────┐ registry.resolve(intent)
22+
│IntentRegistry│ ──────────▶ React component
23+
└──────────────┘
24+
│ renders with
25+
26+
┌─────────────────────────────────────────┐
27+
│ <YourIntent node={n} props={p} slots={s}│
28+
│ data={d} /> │
29+
└──────────────────────────────────────────┘
30+
│ may call
31+
32+
useContractQuery / useContractCommand / useSubscription
33+
34+
▼ (back through ContractClient or SubscriptionMux)
35+
POST /api/dashboard/v1 | GET /api/dashboard/v1/stream
36+
```
37+
38+
The Go side handles auth, permission filtering, dispatch, and audit. The React shell only handles **rendering** — never authorization decisions.
39+
40+
## Five concepts to know
41+
42+
### 1. The graph
43+
44+
The graph is a typed tree of `GraphNode`s. Every node has an `intent` (string), optional `props` (free-form), `data` (a binding to a query intent), and `slots` (named child arrays). The Go server filters out nodes the current user cannot see *before* sending — the shell trusts the response.
45+
46+
```ts
47+
interface GraphNode {
48+
intent: string; // resolved by IntentRegistry
49+
title?: string;
50+
data?: { intent: string; params?: ... }; // data binding
51+
props?: Record<string, unknown>;
52+
slots?: Record<string, GraphNode[]>; // named child arrays
53+
enabledWhen?: Predicate; // for read-only / disabled UI
54+
op?: string; // for action.* — the command intent
55+
payload?: Record<string, unknown>; // ParamSource-shaped
56+
}
57+
```
58+
59+
### 2. The IntentRegistry
60+
61+
A `Map<string, React.ComponentType>` keyed by intent name. `App.tsx` builds it once via `buildIntentRegistry()` and threads it through React context.
62+
63+
```ts
64+
const reg = new IntentRegistry();
65+
reg.register("metric.counter", MetricCounter);
66+
reg.register("page.shell", PageShell);
67+
// ...
68+
```
69+
70+
The `GraphRenderer` looks up `node.intent` in the registry and renders the component, falling back to `<UnknownIntent intent={n.intent} />` when the entry is missing. Future contributors can register their own intents at runtime; slice (e) registers only the v1 vocabulary.
71+
72+
### 3. Slots
73+
74+
Slots are how a parent intent invites children. The renderer never decides where children go — the parent does, by calling `<SlotRenderer slot="<name>" slots={slots} />` somewhere in its JSX.
75+
76+
```tsx
77+
function PageShell({ slots }: IntentComponentProps) {
78+
return (
79+
<>
80+
<header>...</header>
81+
<main>
82+
<SlotRenderer slot="main" slots={slots} />
83+
</main>
84+
</>
85+
);
86+
}
87+
```
88+
89+
The contract registry (Go side) validates at registration time that contributors only fill slots their parent intent declares — no surprises in production.
90+
91+
### 4. ContributorContext + ParentContext
92+
93+
Two React contexts thread the data leaf intents need to issue commands and resolve bindings:
94+
95+
- **`useContributor()`** — the contributor name owning the current graph subtree (e.g., `"users"`). `action.button` posts its `op` to this contributor by default. `App.tsx` wraps `<PageRoute>` in `<ContributorProvider value={...}>` once per route.
96+
- **`useParent()`** — the nearest enclosing record (a row from `resource.list`, the loaded record from `form.edit`, etc.). Lets payload bindings like `{ from: 'parent.id' }` resolve without a centralized state machine. `resource.list` and `resource.detail` are responsible for setting it via `<ParentProvider value={row}>` when rendering their children.
97+
98+
### 5. Bindings (`resolvePayload`)
99+
100+
Manifest payload values may be literals, `{ value: X }`, or `{ from: "scope.path" }`. `resolvePayload` walks a payload map and returns concrete JS values, pulling from `parent`, `session`, and `route` contexts as needed. Used by `action.button`, `action.menu`, `form.edit`, and (for `data.params`) `resource.list` / `form.edit`.
101+
102+
## Authoring a new intent
103+
104+
Three files. ~50 lines of code for a typical CRUD-shaped widget.
105+
106+
### Step 1: write the component
107+
108+
```tsx
109+
// src/intents/users.profile-card.tsx
110+
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
111+
import { useContractQuery } from "../contract/hooks";
112+
import { useContributor, useParent } from "../runtime/context";
113+
import { LoadingNode, ErrorNode } from "../runtime/fallbacks";
114+
import type { IntentComponentProps } from "../runtime/registry";
115+
116+
interface ProfileCardProps {
117+
size?: "sm" | "md";
118+
}
119+
120+
interface ProfileData {
121+
email: string;
122+
joinedAt: string;
123+
// ...
124+
}
125+
126+
export function ProfileCard({ node, props }: IntentComponentProps<unknown, ProfileCardProps>) {
127+
const contributor = useContributor();
128+
const parent = useParent();
129+
const userId = parent?.id;
130+
const query = useContractQuery<ProfileData>(contributor, "user.profile", undefined, { id: userId });
131+
132+
if (query.isLoading) return <LoadingNode />;
133+
if (query.error) return <ErrorNode message={(query.error as Error).message} />;
134+
if (!query.data) return null;
135+
136+
return (
137+
<Card>
138+
<CardHeader><CardTitle>{node.title ?? "Profile"}</CardTitle></CardHeader>
139+
<CardContent>
140+
<p>{query.data.email}</p>
141+
<p>Joined {query.data.joinedAt}</p>
142+
</CardContent>
143+
</Card>
144+
);
145+
}
146+
```
147+
148+
### Step 2: register it
149+
150+
```ts
151+
// src/intents/register.ts
152+
import { ProfileCard } from "./users.profile-card";
153+
// ...
154+
reg.register("users.profile-card", ProfileCard as unknown as IntentComponent);
155+
```
156+
157+
### Step 3: smoke test
158+
159+
```tsx
160+
// test/users.profile-card.test.tsx
161+
// ...standard MSW + render setup, see test/resource.test.tsx for a template
162+
```
163+
164+
That's it. The Go server's contract registry doesn't need to know about your component — it just emits the manifest, and the shell's React tree picks it up by name.
165+
166+
## Adding a shadcn primitive
167+
168+
If you need a new shadcn component (e.g., `Tooltip`):
169+
170+
```bash
171+
pnpm add @radix-ui/react-tooltip
172+
```
173+
174+
Then drop the component file into `src/components/ui/tooltip.tsx`. Use the existing primitives in `src/components/ui/` as templates — they all follow the same pattern (forwardRef + cn() for class merging + tailwind-merge for variant combos).
175+
176+
## Testing strategy
177+
178+
- **Unit / integration:** Vitest + RTL + MSW. Mount the component, intercept the contract endpoint, assert.
179+
- **Test setup polyfills:** [test/setup.ts](./test/setup.ts) provides `ResizeObserver`, `EventSource`, and pointer-capture stubs that Radix primitives need under jsdom.
180+
- **No browser E2E yet.** Playwright is on the roadmap. For now, the smoke test (`test/smoke.test.tsx`) gives end-to-end coverage through the runtime.
181+
182+
## Performance budget
183+
184+
- **JS:** ≤ 300KB gzipped initial. Currently ~120KB (44KB index + 13KB query-vendor + 49KB react-vendor + 14KB Radix primitives spread across chunks).
185+
- **CSS:** ≤ 10KB gzipped. Currently ~5KB.
186+
- **Cold load:** target < 1s on a 3G connection (most admin tools live on faster networks but this keeps the budget honest).
187+
188+
The Vite config splits `react-vendor` and `query-vendor` into their own chunks so they cache across deploys.
189+
190+
## Known limitations / future work
191+
192+
- **`resource.list`** does client-side filtering only. Server-side pagination + sort is slice (f).
193+
- **`form.edit`** uses controlled state, not react-hook-form. zod-based validation can layer on later if forms grow complex.
194+
- **Custom column cells:** `customCell.<col>` slot is designed in slice (a) but not implemented; today, all cells go through the default renderer.
195+
- **iframe escape hatch:** designed but no concrete component until a contributor needs one.
196+
- **Action error handling:** `action.button` swallows command errors silently for v1. A toast pattern is the natural follow-on.
197+
- **Per-event subscription tracing:** explicitly punted (cardinality concerns).
198+
199+
## Why these choices
200+
201+
- **shadcn/ui (vendored)** — accessible Radix primitives styled with Tailwind and copied into our tree, so we own the components and can adjust without upstream churn. Standard for React+TS admin tools.
202+
- **TanStack Query** — pairs naturally with the contract's per-intent `staleTime` declarations and the `meta.invalidates` hint commands return.
203+
- **Zustand for local state** — small (~1KB), hooks-native, no Provider boilerplate. Used only for transient UI state (theme, principal); server state lives in TanStack Query.
204+
- **CSS variables for theming** — same pattern shadcn ships with; allows runtime theme overrides without rebuilding.
205+
- **Vendored components, not npm package** — shadcn's defining philosophy. Component code lives in `src/components/ui/` so we control everything.
206+
- **No custom design system** — we adopt shadcn's defaults rather than build a parallel system. Slice (f) or a follow-on can fork specific components if an admin-tool need demands it.
Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,146 @@
11
# Dashboard Contract Shell
22

3-
The React/TypeScript runtime that consumes the dashboard contract.
3+
The React/TypeScript runtime that consumes the Forge dashboard contract. It fetches a graph from the Go side, walks each intent through a registered React component, and renders the result. shadcn/ui (Radix + Tailwind) provides the primitives.
4+
5+
## At a glance
6+
7+
- **Stack:** TypeScript 5 strict, React 18, Vite 5, React Router v6 data router, TanStack Query 5, Zustand 4, Tailwind CSS 3, Vitest + MSW.
8+
- **UI primitives:** shadcn/ui (vendored) + lucide-react icons. Light / dark / system theme baked in.
9+
- **Bundle size:** ~120KB gzipped JS + ~5KB CSS for the v1 vocabulary; well within the 350KB budget.
10+
- **Built-in intent vocabulary (v1):** `page.shell`, `metric.counter`, `action.button`, `action.menu`, `action.divider`, `form.edit`, `form.field`, `resource.list`, `resource.detail`, `dashboard.grid`, `audit.tail`. Unknown intents render a graceful fallback.
11+
- **Embedded into the Go binary:** `pnpm build` emits `dist/`, which the dashboard extension serves under `/dashboard/contract/static/*` and `/dashboard/contract/app/*` (SPA fallback).
12+
13+
For the architecture deep-dive (how the renderer + registry work, how to author new intents), see [ARCHITECTURE.md](./ARCHITECTURE.md). For the design rationale across slices, see [SLICE_D_DESIGN.md](../SLICE_D_DESIGN.md) and [SLICE_E_DESIGN.md](../SLICE_E_DESIGN.md).
414

515
## Development
616

717
```bash
818
pnpm install
9-
pnpm dev # Vite dev server on :5173, proxies /api/dashboard/* to :8080
19+
pnpm dev # Vite dev server on :5173, proxies /api/dashboard/* to :8080
20+
```
21+
22+
The dev server expects the dashboard binary running on `localhost:8080`. Start it from the repo root:
23+
24+
```bash
25+
go run ./cmd/forge ... # whatever your dashboard entrypoint is
1026
```
1127

28+
Then browse to `http://localhost:5173/dashboard/contract/app/extensions` (or any other pilot route).
29+
1230
## Build
1331

1432
```bash
15-
pnpm build # Emits dist/ — embedded into the dashboard Go binary via //go:embed
33+
pnpm build # tsc --noEmit && vite build → dist/
1634
```
1735

36+
Run `pnpm build` whenever you change shell source before `go build` from the repo root, since the Go side embeds `dist/` via `//go:embed`. CI does this automatically.
37+
1838
## Test
1939

2040
```bash
21-
pnpm test
41+
pnpm test # vitest run
42+
pnpm test:watch # vitest in watch mode
43+
pnpm lint # tsc --noEmit (strict mode + noUncheckedIndexedAccess)
44+
pnpm format # prettier --write
45+
```
46+
47+
All tests use Vitest + React Testing Library. HTTP/SSE is intercepted with MSW; jsdom polyfills (ResizeObserver, EventSource stub, pointer-capture) live in [test/setup.ts](./test/setup.ts).
48+
49+
## Project structure
50+
51+
```
52+
shell/
53+
src/
54+
main.tsx React entry; mounts <App/>.
55+
App.tsx Providers + React Router. Loads principal + theme on mount.
56+
index.css Tailwind layer + shadcn theme tokens (light + dark CSS variables).
57+
contract/
58+
types.ts TypeScript mirror of the Go envelope (Request, Response, GraphNode, ...)
59+
client.ts ContractClient — POST envelope sender with auto-CSRF + idempotency.
60+
sse.ts SubscriptionMux — single EventSource, demuxed by SSE event name.
61+
hooks.ts React Query bindings: useContractGraph / useContractQuery /
62+
useContractCommand / useSubscription.
63+
runtime/
64+
registry.ts IntentRegistry: name → React component map.
65+
context.tsx IntentRegistryProvider, ContributorProvider, ParentProvider.
66+
renderer.tsx GraphRenderer — dispatches a node to its registered component.
67+
slots.tsx SlotRenderer — recursively renders children of a named slot.
68+
fallbacks.tsx UnknownIntent / LoadingNode / ErrorNode (shadcn Alert + Skeleton).
69+
bindings.ts resolvePayload / resolveValue — turns ParamSource references like
70+
{ from: 'parent.id' } into concrete JS values.
71+
auth/
72+
principal.ts Zustand store for the current user (loads /api/dashboard/v1/principal).
73+
lib/
74+
utils.ts cn() — clsx + tailwind-merge.
75+
theme.ts Zustand theme store (light / dark / system, localStorage-backed).
76+
components/
77+
ui/ shadcn primitives (Button, Card, Alert, Sheet, Table, Form, ...).
78+
theme-toggle.tsx Sun / Moon / System dropdown built on shadcn DropdownMenu.
79+
intents/ Registered intent components. One file per intent.
80+
register.ts Builds the IntentRegistry consumed by App.tsx.
81+
page.shell.tsx Topbar + main slot wrapper.
82+
metric.counter.tsx Subscribed numeric value in a Card.
83+
action.button.tsx Issues a kind=command with optional confirm dialog.
84+
action.menu.tsx DropdownMenu of actions.
85+
action.divider.tsx Separator (used standalone or inside action.menu).
86+
form.edit.tsx Form container; preloads via query intent, submits via op command.
87+
form.field.tsx Labeled input — text, email, number, password, textarea, checkbox.
88+
resource.list.tsx shadcn Table with rowActions slot + detailDrawer (Sheet) slot.
89+
resource.detail.tsx dl/dt/dd of a record's fields.
90+
dashboard.grid.tsx Responsive widget grid.
91+
audit.tail.tsx Append-mode subscription with sticky-bottom auto-scroll.
92+
test/
93+
setup.ts MSW + ResizeObserver + EventSource + pointer-capture polyfills.
94+
contract.test.ts ContractClient round-trip tests.
95+
sse.test.ts SubscriptionMux dispatch tests.
96+
renderer.test.tsx Registry, GraphRenderer, SlotRenderer.
97+
smoke.test.tsx Full app mount through the contract endpoint.
98+
actions.test.tsx action.button: dispatch, payload binding, confirm dialog.
99+
form.test.tsx form.edit + form.field: submit, prefill from query.
100+
resource.test.tsx resource.list, resource.detail, dashboard.grid.
101+
embed.go //go:embed dist/* for the Go side.
102+
components.json shadcn config (used if you ever run `shadcn add`).
103+
vite.config.ts Vite build + dev proxy + @ alias.
104+
vitest.config.ts Vitest config (jsdom env, jsdom URL = http://localhost:3000).
105+
tailwind.config.ts Tailwind tokens + animate plugin.
106+
tsconfig.json TS strict mode + noUncheckedIndexedAccess + paths.
22107
```
23108

24-
See [SLICE_D_DESIGN.md](../SLICE_D_DESIGN.md) for the architecture and [SLICE_D_PLAN.md](../SLICE_D_PLAN.md) for the implementation plan.
109+
## Theming
110+
111+
The shell ships shadcn's "slate" defaults across CSS variables in `src/index.css`. Three modes:
112+
113+
- **light** — default `:root` tokens.
114+
- **dark**`.dark` class on `<html>` flips the tokens.
115+
- **system** — follows `prefers-color-scheme`.
116+
117+
User selection persists via localStorage (`forge.dashboard.theme`). The topbar's theme toggle is a `DropdownMenu` of Sun / Moon / Monitor.
118+
119+
To override colors for a deployment, ship a CSS file that re-declares the variables and load it via the dashboard config — or fork `src/index.css`.
120+
121+
## Embedding
122+
123+
The Go side serves the built shell from two route groups (registered in `extensions/dashboard/extension.go`):
124+
125+
| URL pattern | Purpose | Cache |
126+
|---|---|---|
127+
| `/dashboard/contract/static/*` | Hashed assets (JS, CSS, fonts) from `dist/` | Immutable for `/assets/*`, no-cache otherwise |
128+
| `/dashboard/contract/app[/*]` | SPA index.html — React Router handles client-side routing | no-cache |
129+
130+
`pnpm build` is required before `go build` so `embed.FS` picks up the latest assets.
131+
132+
## Adding a new intent
133+
134+
See [ARCHITECTURE.md](./ARCHITECTURE.md). The TL;DR:
135+
136+
1. Drop `src/intents/<intent.name>.tsx` with a function component matching `IntentComponentProps`.
137+
2. Register it in `src/intents/register.ts`.
138+
3. Add a Vitest smoke test under `test/`.
139+
140+
## What's NOT in this slice
141+
142+
- Server-side filtering / sorting / pagination for `resource.list` (client-side only for v1).
143+
- Custom column rendering (`customCell.<col>` slot designed in slice (a) but no concrete renderer yet).
144+
- iframe escape hatch component.
145+
- Browser E2E (Playwright).
146+
- Internationalization, advanced accessibility audit beyond RTL defaults.

0 commit comments

Comments
 (0)