OperantAI Workflow Design System
ComfortableCompact

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 via gray-matter. A file at content/docs/components/button.md serves 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) reads DESIGN-SYSTEM.md/AGENTS.md/ARCHITECTURE.md straight from the repo root instead of duplicating them into content/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 via node: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 pull node:fs into the browser bundle. app/(site)/docs/[[...slug]]/page.tsx is 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-content carries a min-width: 0 that'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.

ProviderFree tier?Env varDefault model
GroqYesGROQ_API_KEYresolved live from Groq's own /models list (see below)
Google GeminiYesGOOGLE_API_KEYgemini-2.0-flash
OpenRouterYes (:free-suffixed models)OPENROUTER_API_KEYmeta-llama/llama-3.3-70b-instruct:free
OpenAINoOPENAI_API_KEYgpt-4o-mini
AnthropicNoANTHROPIC_API_KEYclaude-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, first generateContent-capable entry (prefers one with "flash" in the name).
  • OpenAI → GET api.openai.com/v1/models, prefers a gpt-4o/*-mini entry.
  • 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):

  1. 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 :free models).
  2. Locally: copy apps/web/.env.example to apps/web/.env.local (gitignored — never commit it) and fill in whichever keys you have.
  3. 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.local if you'd rather not copy-paste.
  4. 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 to public/styles/ by scripts/copy-vendor.mjs) and sets the snippet as innerHTML directly. Fully faithful, pixel-accurate render.
  • React mode renders the REAL @operant/ui — not an approximation. scripts/copy-vendor.mjs bundles react + react-dom/client + @operant/ui together in one esbuild pass into public/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 internal from "react" import (both this entry's own, and the ones inside packages/ui/dist that tsup left external on purpose) against the exact same node_modules/react, so there's genuinely one shared React instance behind both the host page and the sandboxed Radix-based components. preview-frame.js's renderReact() transforms the snippet with a vendored Babel standalone build, then instantiates it with Object.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 combined esbuild pass rather than trying to externalize react out of @operant/ui's own build and re-inject it via a global.
    • packages/ui must be built (npm run build --prefix ../../packages/ui, or just npm run build/npm run dev from apps/web — its predev/prebuild scripts already do this first) before copy-vendor.mjs can bundle it; the script throws a clear error if packages/ui/dist doesn't exist yet rather than producing a broken bundle.
    • /vendor/* and /styles/* are served with Cache-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/catch around .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 via window.onerror. Combined with this iframe's intentionally opaque origin, browsers mute that into a content-free "Script error." — so preview-frame.js wraps the rendered component in a real PreviewErrorBoundary class 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 by copy-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/generate is 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.