How Studio works
Every component in Grade ships with a small Markdown sidecar that teaches both humans and language models what the component is, when to reach for it, and what props it takes — plus a Zod-backed contract that lets us validate the output. Those two artefacts drive the Studio chat, the in-preview targeted edits, the docs site, and (soon) the Grade MCP server. One source of truth per component, three downstream consumers, no drift.
Why it lands
Most design systems are documented for humans and then translated by hand into prompts, examples, and screenshots whenever someone wants an LLM to use them. The translation is brittle — it drifts from the source, it bloats every prompt with the full catalog, and it never quite covers the 10% of generation where the model reaches for raw Tailwind instead of the DS. Grade closes that gap by treating model-facing documentation as a first-class artefact colocated with the component.
Five properties make this work:
Single source of truth
A component's .md sidecar lives next to its .tsx source, with a typed .contract.ts auto-generated from the .md alongside. When the code changes the sidecar changes in the same commit, and the contract regenerates on prebuild — so Studio, the docs site, and (soon) the MCP server never see a stale schema. The .md file IS the documentation, the contract IS the runtime type — neither is a derived artefact you have to remember to update.
Lazy retrieval (with canonical examples)
Every chat turn scans the conversation, picks out which components are actually in play, and pastes only those sidecars into the system prompt. A fresh “make me a login form” ships Button, Input, Label, Card — notthe full catalog. The pinned ref isn't just a prop list either: the sidecar's JSX example block and its ### Anti-patterns section get pinned verbatim, so the model sees the canonical composition (compound subcomponent ordering, required wrappers, DO-NOT lines) rather than guessing it from training data. Typical turn carries 1.5k–3k tokens of DS context — a fraction of the 200k window and worth it for the hallucination drop.
Pinned structural grammar
Layout primitives — Stack, Row, Grid, Flex, AppShell — are pinned to everyturn regardless of retrieval. Users almost never say “stack” or “row” out loud, so retrieval alone wouldn't fire, and the model would fall back to hand-rolling flex flex-col gap-2. Pinning the refs + a short before/after section in the system prompt shifted output from raw utilities to DS primitives in a single deploy.
Targeted edits from the preview
Every DS component stamps a data-gds-part attribute on its root. Clicking any rendered element in the Studio preview walks up the DOM, finds the nearest DS part, and ships a selection marker on the next request. The server wraps that marker in a “find this JSX node and modify it in place” stanza, so the model edits exactly the component the user pointed at — no guessing which div they meant.
Contract-validated output
Once the model finishes streaming, the emitted JSX is parsed and every <Component prop=…/> call is validated against the typed contract. Unknown props, invalid enum values, missing required props all surface as structured violations with source locations. The validator runs server-side in /api/chatand logs to the server today; surfacing violations back into the chat UI is the next step. It catches what the prose body pinning doesn't prevent — the gap between “the model saw the example” and “the model wrote the right code.”
The cumulative effect: the Studio can generate a login form, a stat dashboard, or an app shell with correct DS components first-try, and iterating on “make this button bigger” actually edits that button. Adding a new component is a .md file + a regenerate command + a changeset — the chat experience updates with the same publish that ships the code.
The sidecar format
Each component has a sidecar at packages/ui/components/ui/<name>.md, right next to its .tsx source. The file is standard Markdown with a YAML frontmatter block followed by one or more fenced JSX examples and (optionally) a ### Anti-patterns section. Both halves matter: the frontmatter is machine-readable (props, aliases, when-to-use), and the prose body is lifted verbatim into the system prompt so the model sees the canonical composition + DO-NOT rules alongside the schema.
Frontmatter fields
name — canonical PascalCase component identifier. This is what the retrieval regex matches against in conversation text.
import — the import path the model should write. Almost always "@gradeui/ui".
subcomponents — sub-exports the model should import alongside ([CardHeader, CardContent, CardFooter]). A mention of any subcomponent retrieves the parent's sidecar, so “CardHeader” pulls in the full Card reference.
variants / sizes— discrete option lists for CVA-style variant slots. The model sees these as “allowed values” and the contracts generator turns them into Zod enums for runtime validation.
props — bulleted list of props with type, default, and a one-line description. Written as tiny TypeScript-flavored pseudosyntax so the model can infer the shape without us parsing it. The contracts generator parses this list into the typed schema.
when_to_use— prose description of when to reach for this component. The most important field — it steers the model toward the right choice in ambiguous cases and often contains anti-pattern callouts (“reach for Row instead of flex items-center gap-*”).
composes_with — list of components that typically compose with this one. Hints at idiomatic combinations.
aliases— informal synonyms the user might mention. Each component carries aliases drawn from web / shadcn convention, Apple HIG, React Native, and SwiftUI, so a designer working across mobile + web can describe the component in any of those vocabularies and retrieval still fires. Stack's aliases include “vstack”, “vertical stack”, “lazyvstack”.
Body (pinned to the model)
Everything after the closing --- fence — the canonical JSX example(s) and the ### Anti-patterns section. When the sidecar wins retrieval, this body gets pinned to the system prompt verbatim under a labelled “Example & anti-patterns for <Component> ↓” marker. The model is told upfront to read the example block before emitting JSX and to treat DO NOT lines as hard rules.
Worked example: Row
The full sidecar for the Row layout primitive — both halves:
---
name: Row
import: "@gradeui/ui"
subcomponents: []
props:
- gap?: "none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl" (default "md")
- align?: "start" | "center" | "end" | "stretch" | "baseline" (default "center")
- justify?: "start" | "center" | "end" | "between" | "around" | "evenly" (default "start")
- wrap?: boolean (default false)
- asChild?: boolean (default false)
- className?: string
- children: React.ReactNode
when_to_use: Horizontal composition — button groups, inline form rows,
logo + nav rows, anything on one line. Reach for Row instead of
`flex items-center gap-*` so the alignment and spacing are editable
through the settings panel.
composes_with: [Button, Input, Stack (can wrap a Row), any content component]
aliases: [row, hstack, h-stack, horizontal, inline, horizontal layout, lazyhstack]
---
```jsx
// Button group — justify="end" pushes the group to the right.
<Row gap="sm" justify="end">
<Button variant="ghost">Cancel</Button>
<Button>Save</Button>
</Row>
```
### Anti-patterns
DO NOT add `flex flex-row` to className — Row already applies it.
DO NOT reach for inline `justify-end` on className — use the `justify`
prop so the settings panel can mutate it.Contracts — the typed projection
Each sidecar has a sibling <name>.contract.tsthat's auto-generated from the .md by scripts/generate-contracts.mjs. The contract is the typed projection of the sidecar — same data, machine-checkable shape. Three things consume it:
- Studio's settings panel — reads
contract.props[name].designto decide which control to render per prop (knob → Switch/Select/ToggleGroup, content → Input, plumbing → hidden). - The JSX validator —
apps/docs/lib/qa/validate-jsx.tswalks the model's output, callscontract.props[name].schema.safeParse(value)on every used prop, and reports unknown props / invalid enum values / missing required props. - The component's own TS types — eventually via
type Props = InferProps<typeof Contract>on the consuming component, so the React API type IS the contract's projection. MediaSurface uses this today; other components will migrate.
// AUTO-GENERATED from row.md by scripts/generate-contracts.mjs.
// The sidecar is the source of truth; the contract is its typed projection.
import { z } from "zod";
import { contract } from "@gradeui/contracts";
export const RowContract = contract({
name: "Row",
description: "Horizontal composition...",
import: "@gradeui/ui",
aliases: ["row", "hstack", "h-stack", "horizontal", ...],
props: {
gap: {
schema: z.enum(["none", "xs", "sm", "md", "lg", "xl", "2xl"]).optional(),
design: "knob",
description: "Inter-child spacing",
default: "md",
},
justify: {
schema: z.enum(["start", "center", "end", "between", "around", "evenly"]).optional(),
design: "knob",
description: "Main-axis distribution",
default: "start",
},
// …
},
});Hand-authored contracts (Carousel, MediaSurface — anything with discriminated-union props or imperative actions) are preserved by the generator on every run via a // AUTO-GENERATED marker check.
The pipeline
A chat turn in Studio moves through six distinct layers. Each one owns a specific transformation — keeping the boundaries sharp is why the system stays debuggable as it grows.
user prompt + selection
│
▼
[1] buildSystemPrompt() packages/studio/src/playbook/prompts/system.ts
│ rules + LAYOUT PRIMITIVES block
│ ALLOWED_COMPONENTS injected from the playbook allowlist
▼
[2] useChat → POST /api/chat apps/docs/components/studio/studio-chat.tsx
│
▼
[3] server composition apps/docs/app/api/chat/route.ts
│ system = systemPrompt + refsBlock + selectionBlock
│ ├─ refsBlock: relevantComponentNames + PINNED_COMPONENTS
│ │ (formats frontmatter + pins prose body
│ │ verbatim — Fix A, May 2026)
│ └─ selectionBlock: targeted-edit stanza
▼
[4] streamText (AI SDK) provider of choice
│
▼
[5] Fast Frame preview apps/docs/app/fast-sandbox/page.tsx
│ eager-imports * from @gradeui/ui at build time;
│ sucrase-compiles model JSX inside the iframe;
│ resolves imports against the pre-loaded namespaces.
│ No npm fetch per turn; no Sandpack round-trip.
▼
[6] Validator pass apps/docs/lib/qa/validate-jsx.ts
On streamText.onFinish: extract the fenced jsx block,
walk every <Component> against COMPONENT_CONTRACTS,
log unknown props / invalid enums / missing required
props server-side. Surfacing into the chat UI is the
next step.[1] The base system prompt
Built once in buildSystemPrompt() in packages/studio/src/playbook/prompts/system.ts. Ten numbered OUTPUT RULES (respond with a sentence + one fenced jsx block, import from the @gradeui/ui barrel, use only allowlisted components, etc.) followed by the LAYOUT PRIMITIVES section with concrete flex… → <Row…> mappings. The ALLOWED_COMPONENTS list (now in packages/studio/src/playbook/components/allowlist.ts) is inlined so the model sees exactly what's available.
[2] Chat UI → /api/chat
studio-chat.tsx wraps the AI SDK's useChathook. Each send ships the message history, the active provider + model, any BYOK API key, the system prompt, and — when the user has clicked the “Select” tool in the preview — the selection payload.
[3] Server composition
The system message that actually reaches the model is three blocks joined with blank lines: the client-built rules + layout section, the component-refs block, and (optionally) the selection block. The refs block is where the interesting stitching happens:
const relevant = Array.from(new Set([
// Always-on structural grammar — pinned regardless of retrieval.
...PINNED_COMPONENTS.filter(inAllowlist),
// Lazy retrieval — pulls in whatever matches the conversation.
...relevantComponentNames(textFromMessages(messages)).filter(inAllowlist),
]));
const refsBlock = renderComponentRefsBlock({ onlyFor: relevant });relevantComponentNames regex-matches each sidecar's name + subcomponents + aliases against the full conversation text, word-boundary, case-insensitive, with a lightweight plural suffix. renderComponentRefsBlockthen formats each matched component's sidecar — frontmatter fields as a compact one-line-per-field block, followed by the full prose body (JSX example + anti-patterns) under a labelled section. That last part is Fix A: previously the body only rendered to humans on the docs page; now the model sees the canonical composition verbatim, which closed the “guessed props” failure mode for compound components like Carousel and MultiSelect.
[4] Targeted edits
When the user clicks an element in the preview with the Select tool active, the iframe walks up the DOM to the nearest [data-gds-part], derives the owning DS component name, and postMessages the element's outerHTML + the PascalCase component identifier to the parent. On the next send, renderSelectionBlockwraps that payload in a “TARGETED EDIT — find the matching <ComponentName>JSX and modify its props in place” stanza. The model edits that instance instead of rewriting the whole composition.
[5] Fast Frame
Studio's preview is apps/docs/app/fast-sandbox/page.tsx — a normal Next route mounted in an iframe by components/studio/fast-frame.tsx. It eager-imports the entire @gradeui/ui namespace plus lucide-react, recharts, etc. at build time. When the model emits JSX, sucrase compiles it inside the iframe and any import paths the snippet uses get resolved against those pre-loaded namespaces. No npm fetch per compile, no Sandpack round-trip — first page load is one Next chunk; subsequent compiles are instant.
The Sandpack-based renderer still exists (sandpack-frame.tsx) as a parity check — flip the renderer over when something looks suspicious in Fast Frame to confirm the bug reproduces against a real npm install @gradeui/ui. Not the default path, not deleted on purpose.
[6] Validator pass
On streamText.onFinish, the chat route extracts the fenced jsx block from the response and runs validateJsx(jsx, { contracts: COMPONENT_CONTRACTS }). The validator parses the JSX with the TypeScript compiler API, walks every <Component prop=…/>, looks up the contract from the registry, and validates each used prop against the Zod schema. Output is structured (severity + kind + component/prop + source location) and logged server-side as a one-liner per violation. Fix B is the safety net for what Fix A doesn't prevent — the model still drifts sometimes, and the validator catches it before the consumer does.
Adding an AI-ready component
A new component needs six things to show up end-to-end in the Studio. None of them are optional, but together they take maybe 20 minutes.
- The component itself —
packages/ui/components/ui/<name>.tsxplus the docs mirror atapps/docs/components/ui/<name>.tsx. Stampdata-gds-part="<name>"on the root so targeted edits can find it. Use semantic theme tokens (bg-card,text-foreground,border-border) and expose sizing knobs as--rds-<name>-*CSS variables. - The sidecar —
packages/ui/components/ui/<name>.mdnext to the.tsx, with the frontmatter fields above plus at least one```jsxexample and (strongly recommended) an### Anti-patternssection. Pick aliases that span web/HIG/RN vocabularies so retrieval fires no matter how the user describes the component. - The contract — auto-generated by
pnpm -F @gradeui/ui generate:contracts. For components with discriminated-union props, imperative actions, or unusual control kinds (glyph picker, colour picker), hand-author the contract and the generator will preserve it via the// AUTO-GENERATEDmarker check. MediaSurface and Carousel are the worked examples. - Allow-list + barrel + nav — add the name to
ALLOWED_COMPONENTSinpackages/studio/src/playbook/components/allowlist.tsso Studio will emit it, export it frompackages/ui/lib/index.ts, and add it tocomponentsListinapps/docs/lib/components-list.ts+ the nav inapps/docs/components/docs-sidebar.tsxso the human docs pick it up. Updatepackages/ui/COMPONENTS.mdfor the inventory. - A doc page —
apps/docs/app/components/<name>/page.tsxwith the usual header + usage + props table + composition demos. The<SidecarBlock slug="<name>" />renders the .md file inline at the bottom so the prose stays in sync with what the model sees. - A changeset —
pnpm changesetwith a minor bump and a one-line changelog entry. The release bot handles npm publish.
If the component is structurally universal (the model would want it on most turns even when the user doesn't name it), consider adding it to PINNED_COMPONENTS in the playbook. Pin sparingly — every pinned component pays token cost on every turn. Today only the five layout primitives are pinned.
What's next
The sidecars + contracts are authored once and consumed by an expanding set of surfaces. Four upcoming consumers / refinements:
@gradeui/mcp
An MCP server exposing the same playbook (sidecars + contracts + reference layouts) as tools and resources. Drop it into Claude Desktop, Cursor, Windsurf, or any MCP-capable client, and the assistant there gains the same Grade vocabulary the Studio has — outside the Studio, in the user's own editor or chat.
Validator → chat surface
Today Fix B logs violations to the server. The next step is surfacing them as a small ⚠ chip on the assistant message with the count, click-to-expand the full list. The metadata channel that carries usage and refs already does this for token + ref data — the violations fit the same pipe.
Subcomponent contracts
The validator skips <Carousel.Slide>-style compound calls today because their props don't live on the root contract. Splitting each compound component into per-subcomponent contracts lets the validator catch drift on subcomponent props too — at the cost of more contract files. A pragmatic middle ground: declare subcomponent prop maps inline on the root contract under a new subcontracts field.
Auto-rendered ComponentProps
Replace the hand-authored <PropsTable /> on every component docs page with <ComponentProps contract={ButtonContract} /> that reads the registry directly. Docs update when the contract changes; no per-page maintenance.