Architecture
This document explains how the repo fits together: the monorepo layout, the AI generator's provider fallback chain and key storage model, the live-preview sandbox, and deployment. For the design language itself (tokens, components, rules), see DESIGN-SYSTEM.md and AGENTS.md — this file is about the systems around that design language, not the design language itself.
Why this exists
Operant started as a static, framework-free HTML/CSS design system (index.html, css/,
examples/) — deliberately zero-build, so both humans and coding agents could read the whole
system in a few files. This document covers the layer added on top: a documentation site, a live
playground, an AI component generator, and a Storybook catalog — plus a real React component
package, since the generator and Storybook both need one. The original static files have been
folded into the Next.js app below (there is no more parallel static site).
Monorepo layout
packages/
styles/ @operant/styles — re-exports css/tokens.css + base.css + components.css as an
npm-consumable package. It never duplicates that CSS; every file in it is a one-line
@import back to the repo-root css/ files, which stay the single source of truth.
ui/ @operant/ui — React components. Each wraps Radix UI (for keyboard/focus/ARIA
behavior) but renders the EXISTING .ods-* class names from components.css via CVA
variant maps — never Tailwind, never a second class system (AGENTS.md #2). If a
component needed CSS that didn't exist yet (Checkbox, RadioGroup, Switch, Select,
DropdownMenu, Popover, Tooltip), that CSS was added to css/components.css /
css/tokens.css FIRST, following the same token/grammar rules as everything else —
the React layer never invents its own visual language.
apps/
web/ Next.js 15 App Router app. Two route groups (URL-transparent — they don't add a path
segment) split the chrome:
app/(site)/ home, /docs, /examples (index + component gallery) — the shared
SiteHeader + a padded max-width column + footer (site-chrome.css).
app/(tool)/ /playground, /generator, /storybook, and the 3 full-screen example
demos (/examples/dashboard, /examples/approval-inbox,
/examples/run-detail) — SiteHeader only, no footer, full viewport
height below it (`.tool-shell`). These are workbenches/standalone
screens, not reading pages, so they get the whole viewport instead
of the site's 1080px-ish reading column. The 3 example demos each
restore their OWN original app-shell (left nav + own topbar) rather
than sitting inside the site's chrome twice.
storybook/ @storybook/react-vite catalog of every @operant/ui component, built straight from
packages/ui/src (no prebuilt dist/ required — Vite compiles the TS source live).
Package manager: npm workspaces (not pnpm/Turborepo like the design-forge reference this was built from) — chosen because this repo doesn't publish packages to npm or need Turborepo's build caching at this scale; npm workspaces need no extra global tool install.
Docs content system
/docs is a categorized, per-topic site (Getting Started / Components / Ecosystem Tools /
Reference — the same shape as the design-forge reference), not a raw dump of a few markdown files:
apps/web/content/docs/**/*.md— one file per topic (installation.md,getting-started/theming.md,components/button.md, ...), each with YAML frontmatter (title,description) parsed viagray-matter. A file atcontent/docs/components/button.mdserves at/docs/components/button. 25 pages total: 4 Getting Started, 18 one-per-component (matching the Wave 1 list below), 3 Ecosystem Tools guides (Generator/Playground/Storybook).- Reference (
/docs/reference/design-system,/docs/reference/agents,/docs/reference/architecture) readsDESIGN-SYSTEM.md/AGENTS.md/ARCHITECTURE.mdstraight from the repo root instead of duplicating them intocontent/docs/— those three stay the single source of truth for humans and coding agents reading the raw files. apps/web/lib/docs.ts(server-only — reads vianode:fs) does the file loading;apps/web/lib/docsNav.ts(client-safe, no fs) holds just the sidebar's category/label/slug structure so the client-rendered sidebar component doesn't pullnode:fsinto the browser bundle.app/(site)/docs/[[...slug]]/page.tsxis the single catch-all route for all of it.- Wide tables/code blocks in rendered markdown scroll inside their own container
(
.docs-table-wrap,.docs-markdown pre) — never the page — and.docs-contentcarries amin-width: 0that's load-bearing for that on a CSS grid.
Why a React package at all, if Operant is framework-free?
Two of the four requested features — the AI generator's live preview and Storybook — are
substantially better with real components than with regenerated markup strings: Storybook needs
something to catalog with controls/a11y checks, and a generator that can also emit typed React
components (not just HTML strings) is strictly more useful. @operant/ui exists for those two
consumers. The CSS remains the actual design system — @operant/ui is a thin, optional
behavioral layer on top, and plain HTML/CSS usage (the original audience) is unaffected and still
the generator's default output mode.
Component coverage
Wave 1 (shipped): Button, Badge, Card, Input/Field, Textarea, Checkbox, RadioGroup,
Switch, Select, Tabs, Modal, Popover, Tooltip, DropdownMenu, Stepper, Composer,
ApprovalBar, Response.
Backlog (CSS already exists in components.css; no React wrapper yet — same pattern, port on
demand): Accordion, Avatar, Progress, Toast, Skeleton, Table, Log, Diff, Metric,
Confidence, Run card, Command palette, Node (workflow graph). Not in either CSS or React
yet: Slider, ContextMenu, HoverCard, AlertDialog, AspectRatio, a dedicated Alert/
Banner.
AI provider fallback chain
apps/web/lib/ai/providers.ts defines a provider registry and an ordered fallback chain, read by
apps/web/app/api/generate/route.ts (a streaming Node.js serverless function).
System prompt — concise and purpose-built, not a document dump
apps/web/lib/ai/systemPrompt.ts builds a dedicated ~4.5–5KB, 5-section prompt (Role → .ods-*
class or @operant/ui component reference → Conventions → Accessibility → Output format, each
with a worked example and a checklist) — mirroring the same structure design-forge's own
PromptBuilder uses, for the same reason: a short, example-driven, checklist-terminated prompt is
followed far more reliably by smaller/free models than a long reference document. An earlier
version of this file fed the model the entire AGENTS.md (~8KB, written for a general coding
agent, covering far more than one-shot generation needs) verbatim as the system prompt — that
measurably increased the odds of a free model going off-task (e.g. answering a question about the
design system instead of writing one) rather than reliably returning one fenced code block.
apps/web/app/(tool)/generator/page.tsx also guards the result: if the final response still
doesn't look like code for the requested mode (no tag for HTML, no export default function for
React), it's surfaced as an error instead of being displayed as if it were a real result.
| Provider | Free tier? | Env var | Default model |
|---|---|---|---|
| Groq | Yes | GROQ_API_KEY | resolved live from Groq's own /models list (see below) |
| Google Gemini | Yes | GOOGLE_API_KEY | gemini-2.0-flash |
| OpenRouter | Yes (:free-suffixed models) | OPENROUTER_API_KEY | meta-llama/llama-3.3-70b-instruct:free |
| OpenAI | No | OPENAI_API_KEY | gpt-4o-mini |
| Anthropic | No | ANTHROPIC_API_KEY | claude-3-5-haiku-latest |
Default order is groq,google,openrouter,openai,anthropic — override with AI_PROVIDER_ORDER
(comma-separated). Override any resolved model with AI_<PROVIDER>_MODEL (e.g. AI_GROQ_MODEL).
Every provider's model id is resolved LIVE, not hardcoded — every provider's catalog can
rotate/deprecate model ids over time, so lib/ai/providers.ts asks each provider's own
models-list endpoint what's actually available on that specific key right now, rather than
shipping an id that can silently go stale:
- Groq →
GET api.groq.com/openai/v1/models, first non-audio/non-moderation entry. - Google →
GET generativelanguage.googleapis.com/v1beta/models, firstgenerateContent-capable entry (prefers one with "flash" in the name). - OpenAI →
GET api.openai.com/v1/models, prefers agpt-4o/*-minientry. - Anthropic →
GET api.anthropic.com/v1/models, prefers a "haiku" entry. - OpenRouter →
GET openrouter.ai/api/v1/models, filtered to:free-suffixed entries.
Each resolver falls back to a hardcoded id only if that live call itself fails (including a hard
5-second timeout via AbortSignal.timeout — a slow models-list endpoint can't stall generation),
and is cached per (provider, apiKey) for the process lifetime — so two different BYOK visitors'
keys never share a cache entry, and each key only pays the models-list round trip once. Every
provider is optional — only ones with a key (server env var, or a BYOK header, see below) are
attempted; the route returns 503 NO_PROVIDER_CONFIGURED only if none has a key at all.
At request time, the route walks the resolved provider list in order and, for each, starts a
streamText() call and pulls its first chunk before committing the HTTP response. If a provider
throws before yielding anything (bad/missing key, model unavailable, outage), it's skipped and the
next candidate is tried — the caller never sees a half-broken stream from a failed provider, only
a clean stream from whichever provider actually worked, exposed via the X-AI-Provider response
header.
Saving API keys securely
Recommended — server-side env vars (Vercel encrypted environment variables):
- Get a free Groq key at https://console.groq.com/keys (and/or a free Gemini key at
https://aistudio.google.com/apikey, and/or a free OpenRouter key at
https://openrouter.ai/keys for its
:freemodels). - Locally: copy
apps/web/.env.exampletoapps/web/.env.local(gitignored — never commit it) and fill in whichever keys you have. - On Vercel: Project Settings → Environment Variables → add the same names, scoped to
Development/Preview/Production as needed. Vercel encrypts these at rest; they're never sent to
the browser. Pull them locally with
vercel env pull apps/web/.env.localif you'd rather not copy-paste. - Never put a key in
NEXT_PUBLIC_*— that prefix is bundled into client JS.
Bring your own key (BYOK) — for a visitor who doesn't want to touch server config, or a fully
personal deployment with no shared secret at all: the Generator page has a "Use my own API key"
panel (apps/web/lib/useByokKeys.ts) that stores whatever key you paste in that browser's
localStorage only, and sends it as an x-byok-<provider> request header on calls to this site's
own /api/generate route. It is never logged and never written to any server-side store — the
route reads the header, uses it for that one request, and discards it. This is not the same as
"the key never touches a server": it still passes through your own /api/generate function on its
way to the provider (that function needs to attach it to the outbound API call), it's just never
persisted there. Treat a BYOK key the same as any other secret kept in browser storage: fine for
personal/single-user use on a device you trust, not a substitute for real secret management on a
shared/public deployment.
Live preview sandbox
Both /playground and /generator render into the same <PreviewFrame> component
(apps/web/components/PreviewFrame.tsx), which points a sandboxed <iframe sandbox="allow-scripts"> (deliberately no allow-same-origin — the iframe is an opaque
origin with no access to this site's cookies, storage, or DOM — including this browser's BYOK
keys, which live in this site's own localStorage) at public/preview-frame.html. Communication
is via postMessage only (RENDER, THEME, RUN_AXE/AXE_RESULTS,
RENDER_OK/RENDER_ERROR, CONSOLE).
- HTML mode (the default — matches Operant's actual framework-free identity) loads the real
css/tokens.css/base.css/components.css(copied topublic/styles/byscripts/copy-vendor.mjs) and sets the snippet asinnerHTMLdirectly. Fully faithful, pixel-accurate render. - React mode renders the REAL
@operant/ui— not an approximation.scripts/copy-vendor.mjsbundlesreact+react-dom/client+@operant/uitogether in oneesbuildpass intopublic/vendor/react-bundle.js(window.React/window.ReactDOMClient/window.OperantUI). Bundling all three in a single pass — rather than externalizing react/react-dom out of@operant/ui's own build — means esbuild resolves every internalfrom "react"import (both this entry's own, and the ones insidepackages/ui/distthattsupleft external on purpose) against the exact samenode_modules/react, so there's genuinely one shared React instance behind both the host page and the sandboxed Radix-based components.preview-frame.js'srenderReact()transforms the snippet with a vendored Babel standalone build, then instantiates it withObject.keys(window.OperantUI)injected as the available import names — so HTML mode and React mode render identically for equivalent markup, because React mode really is just the same components applying the same.ods-*classes, not a hand-rolled stand-in for them. This is a deliberate improvement over the design-forge reference this was built from, which uses hand-rolled visual stubs instead for the same problem — bundling turned out to be tractable here once done as one combinedesbuildpass rather than trying to externalize react out of@operant/ui's own build and re-inject it via a global.packages/uimust be built (npm run build --prefix ../../packages/ui, or justnpm run build/npm run devfromapps/web— itspredev/prebuildscripts already do this first) beforecopy-vendor.mjscan bundle it; the script throws a clear error ifpackages/ui/distdoesn't exist yet rather than producing a broken bundle./vendor/*and/styles/*are served withCache-Control: no-cache, must-revalidate(next.config.mjs) — these are regenerated on every dev/build start, and a stale cached copy after a rebuild (e.g. a browser still holding last session's bundle) is otherwise a real, confusing failure mode ("X is not defined" for something that's clearly in the bundle on disk).- React deliberately does not let a render-phase error reach a plain
try/catcharound.render()— without an error boundary present, it re-throws the error asynchronously so an error boundary elsewhere could catch it, which means an unhandled one only surfaces later viawindow.onerror. Combined with this iframe's intentionally opaque origin, browsers mute that into a content-free "Script error." — sopreview-frame.jswraps the rendered component in a realPreviewErrorBoundaryclass component, which catches it synchronously within React's own flow instead and reports the real message. - Axe-core (
public/vendor/axe.min.js, also copied bycopy-vendor.mjs) runs inside this same sandbox on demand (RUN_AXE→axe.run(root)→AXE_RESULTS), wired to the Playground's "a11y Audit" button.
Local development
npm install # from the repo root — installs all workspaces
npm run dev # apps/web on http://localhost:3000
npm run dev:storybook # apps/storybook on http://localhost:6006 (optional, separate terminal)
npm test # runs packages/ui's Vitest suite
Known limitations / honest gaps
- The static-analysis validation badges in the Generator (
apps/web/lib/ai/validate.ts) are regex heuristics (no raw hex, uses.ods-*, allowed imports only) — not a real ESLint/TypeScript pass. The real accessibility check (axe-core) is wired up, but only on-demand in the Playground's "a11y Audit" button, not automatically on every Generator result. - The in-memory rate limiter in
/api/generateis per server instance, not shared across instances — fine to deter casual abuse, not a real multi-instance rate limit. A shared store (Vercel KV/Upstash) would be the next step if this is ever exposed publicly at scale. - The Generator is single-turn — each Generate call is an independent request with no conversation history sent to the model (the chat-style sidebar is a display of past prompts/results, not a multi-turn context window). Follow-up prompts like "make it smaller" won't have the previous result as context.