|
| 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. |
0 commit comments