# Chai UI > Smart, drop-in React and Vue components for the input and output surfaces of > generative-media apps: a prompt composer, a media-to-prompt analyzer, a > result card, and an edit card for precise image edits, plus a hook (or > composable) that wires them to real AI engines (Fal.ai, OpenRouter, Vercel AI Gateway, or your own). > Controlled components, swappable engines, design tokens. This file is for coding agents integrating `@chai-ui/react` or `@chai-ui/vue`. It covers only what exists today (last updated 2026-10-09). If something isn't listed here, assume it doesn't exist. The reference below uses React names; "Vue" says exactly how they map. **Read `DESIGN.md` too.** It ships inside each package, at `node_modules/@chai-ui/react/DESIGN.md` or `node_modules/@chai-ui/vue/DESIGN.md`, and has the usage rules: which component to pick, which tokens exist, the built-in AI states and what not to build. ## Install ``` npm install @chai-ui/core @chai-ui/react @chai-ui/tokens ``` ```tsx import "@chai-ui/tokens/css"; // first: defines every --chai-* variable import "@chai-ui/react/style.css"; // second: component styles read those variables ``` ## Fastest working setup ```tsx import { useState } from "react"; import { Composer, ResultCard, useComposer, createFalEngine, type ComposerAttachment } from "@chai-ui/react"; export function App() { const [value, setValue] = useState(""); const [attachments, setAttachments] = useState([]); // With no `engine`, useComposer uses the mock engine: no key needed, placeholder output. const { run, runs, submit } = useComposer({ engine: createFalEngine({ outputKind: "image" }) }); return ( <> {/* Every run's results: one per model, and one run per use case. */} {run && r.results)} prompt={run.request.prompt} onAction={() => {}} />} ); } ``` With no model picked, the engine's own default model runs. With no use case picked, the prompt is a text request (see `defaultUseCase` below). ## Vue (`@chai-ui/vue`) The same components, props and behavior as React (Vue 3.5 or later; Nuxt uses this package too). Install `@chai-ui/vue` in place of `@chai-ui/react`, and import `@chai-ui/vue/style.css`. Everything below applies, with these name changes: - `value` / `onChange` is `v-model`. - Each other `x` / `onXChange` pair is `v-model:x` (kebab-case in templates): `v-model:attachments`, `v-model:model-id`, `v-model:model-ids`, `v-model:use-cases`, `v-model:aspect-ratio`, `v-model:auto-select-model`, `v-model:regions`, `v-model:prompt-length`, `v-model:active-version`. - Other callbacks are events: `onSubmit` is `@submit`, `onAction` is `@action`, `onEnhance` is `@enhance`, `onAbort` is `@abort`. As in React, some controls show only with a listener: the Enhance button with `@enhance`, stop with `@abort`, the Model menu with `@update:model-id`, and so on. - `useComposer` and `useMediaAnalyzer` are composables returning refs (`run.value`, `edit.value`). Pass a getter for options that change: `useComposer(() => ({ engine, editImage: image.value }))`. - `ChaiProvider` is a component taking the same props, wrapping the components that use it. - `EditCard` takes `edit`'s fields one by one (`:versions`, `:active-version`, `:regions` with their `@update:` events) rather than a spread. ```vue ``` ## Components (`@chai-ui/react`) ### `Composer`: the prompt bar ```ts interface ComposerProps { value: string; // required, controlled onChange: (value: string) => void; // required onSubmit: (payload: ComposerSubmitPayload) => void; // required; retry re-fires it with the same payload placeholder?: string; attachments?: ComposerAttachment[]; // DroppedMedia & { id } onAttachmentsChange?: (next: ComposerAttachment[]) => void; onUnsupportedFile?: (fileName: string) => void; onAttachMenuSelect?: (action: "add-media" | "create-image" | "create-video" | "create-world" | "edit-image" | "create-speech" | "create-music") => void; attachMenuActions?: ComposerAttachMenuAction[]; // which + items show; default all except "create-world", "create-speech" and "create-music", which are opt-in regions?: EditRegion[]; // edit mode only: colored region chips (see EditCard) onRegionsChange?: (next: EditRegion[]) => void; // removing a chip removes its region useCase?: { kind: OutputKind; label: string; edit?: boolean; audio?: "speech" | "music" } | null; // the chip; OutputKind = MediaKind | "world"; aspect ratio only for image/video, not edits. CREATE_SPEECH_USE_CASE / CREATE_MUSIC_USE_CASE are { kind: "audio", audio } onClearUseCase?: () => void; defaultUseCase?: ComposerUseCase; // used when no use case is picked; default TEXT_USE_CASE models?: ModelOption[]; // optional: leave it out and the Model menu offers Chai's suggested Fal models for the use case (nothing picked: submit waits for a pick or auto-select; with no onModelChange there's no menu and the first one runs); pass [] for none. Labels are short names, no parentheses; each model's `aspectRatios` (e.g. ["1:1", "16:9"]) fills the Aspect ratio menu. Attachments don't change the list: with images attached, the Fal engine calls each model's `withImages` endpoint, and the picked ratio still applies modelId?: string | null; onModelChange?: (id: string | null) => void; // null when auto-select turns on, until a submit routes. The Model menu shows only with this (onModelIdsChange for multi-select); the Aspect ratio menu only with onAspectRatioChange // Builder opt-ins, both off by default: multiSelectModels?: boolean; // pick several models; each gives its own result modelIds?: string[]; // used instead of modelId when multi-select is on onModelIdsChange?: (ids: string[]) => void; multiSelectUseCases?: boolean; // advanced: Image and Video from one prompt, a Model menu each, a model needed in each before submit. Every other use case runs alone: in onAttachMenuSelect, setUseCases(prev => nextUseCases(prev, picked)). Show each run in its own ResultCard useCases?: ComposerUseCase[]; // used instead of useCase when on onUseCasesChange?: (next: ComposerUseCase[]) => void; modelsByKind?: Partial>; // the Model menu's list per picked use case autoSelectModel?: boolean; // true = useComposer picks the model at submit (see "Auto-select") showAutoSelectToggle?: boolean; // lets the end user flip autoSelectModel onAutoSelectModelChange?: (value: boolean) => void; aspectRatios?: ParameterOption[]; // optional: the menu comes from the selected models' `aspectRatios`; this only relabels or shortens it aspectRatio?: string | null; onAspectRatioChange?: (value: string) => void; onEnhance?: (value: string, field: "prompt" | "environment" | "character", mode?: GenerationUseCase) => void; // omit = no "optimize prompt" button; field = which box (world has two); mode = the use case on screen ("text-to-text" with none picked). Pass useComposer's `enhance` directly enhancing?: boolean; // Create world (opt-in: add "create-world" to attachMenuActions). With useCase { kind: "world" }, the prompt // becomes two fields: Environment (= value, required) and Character (optional, shows with onCharacterChange). character?: string; onCharacterChange?: (value: string) => void; frameRate?: number | null; // menu from the selected models' `frameRates` onFrameRateChange?: (value: number) => void; // Create speech and Create music (opt-in: add "create-speech" / "create-music" to attachMenuActions). speechVoice?: string | null; // Voice menu, once exactly one model is picked: that model's `voices` ({ value, label, group? }). Not voiceInput (dictation) onSpeechVoiceChange?: (value: string) => void; genres?: string[]; // Genre menu, several at once: the genres every picked music model lists in `genres` onGenresChange?: (values: string[]) => void; // Which way every menu in the bar opens (+, Model, Aspect ratio, Voice, Genre, Frame rate): menuPlacement?: "auto" | "above" | "below"; // "above" for a bar docked to the bottom of the screen, "below" for one docked to the top. Default "auto": measured when a menu opens, below if it fits, else above if that fits, else toward the side with more room // Voice input (opt-in, no default model): a mic left of submit; the transcript is added to the prompt, never sent. voiceInput?: { model: string; maxSeconds?: number } | { transcribe: (audio: { src: string; kind: "audio"; mimeType: string }, options?: { signal?: AbortSignal }) => Promise; maxSeconds?: number }; // { model: "fal-ai/wizper" } = any Fal speech-to-text model, through your /api/chai/fal route (list it in allowedModels if set). // { transcribe } = any other provider. maxSeconds defaults to 60. submitError?: string | null; // app-owned; submit becomes retry + inline message submitting?: boolean; // request in flight onAbort?: () => void; // with submitting: submit becomes a stop button onEvent?: (event: ChaiEvent) => void; // front-end analytics: one event per interaction, see Analytics below submitDisabled?: boolean; disabled?: boolean; } interface ComposerSubmitPayload { value: string; attachments: ComposerAttachment[]; useCase: ComposerUseCase; // picked, or defaultUseCase; the first with multiSelectUseCases modelId: string | null; // the first picked model; null = engine default aspectRatio: string | null; selections: { useCase: ComposerUseCase; modelIds: string[]; models: ModelOption[] }[]; // one per use case; models = what auto-select picks from autoSelectModel: boolean; character?: string; // world only: the Character field frameRate?: number | null; // world only: the picked frame rate speechVoice?: string | null; // speech only: the picked voice (the engine gets it as `voice`) genres?: string[]; // music only: also written into the prompt as "Genre: …" (promptWithGenres) } ``` **Create speech and Create music:** opt-in, and Chai suggests no models: pass your own with `voices` (speech) or `genres` (music) and route them with `useComposer({ engineByKind: { audio } })`. No music model on Fal takes genre as an input, so genres go into the prompt. For a Fal model whose schema takes the words as `text`, set `falInput: { prompt: "text" }` and pass your models to `createFalEngine({ models })`. **Create world:** no world model is hosted on Fal or OpenRouter yet, so Chai suggests none. Pass your own `models` (with `frameRates`) and route worlds to your own engine with `useComposer({ engineByKind: { world } })`; `character` and `frameRate` reach its `generate()`. Show the world in your own viewer. **Voice input:** Chai records (WebM in Chrome and Firefox, MP4 in Safari), shows a waveform and a stop button, and hides submit until the transcript is in the prompt. Fal speech-to-text ids include `fal-ai/wizper`, `fal-ai/elevenlabs/speech-to-text/scribe-v2`, `fal-ai/cohere-transcribe`, `nvidia/nemotron-asr-multilingual/asr` and `fal-ai/speech-to-text/turbo`. The builder chooses; never pick one for them. With no use case picked, a submit is a text request (`TEXT_USE_CASE`, `{ kind: "text", label: "Text" }`): it gets a text reply. Set `defaultUseCase` to change that, e.g. to image for an image app. The default is never shown as a chip. ### `MediaAnalyzer`: media in, prompt out Takes an image (up to 5), a video or an audio clip, one media kind at a time. Shows kind-aware model menus and a prompt-length picker. Props: `attachments`, `onAttachmentsChange`, `modelId`, `onModelChange` (`null` when auto-select turns on), `autoSelectModel`, `onAutoSelectModelChange`, `promptLength`, `onPromptLengthChange`, `promptLengthOptions`, `onSubmit(payload: { attachments, modelId, autoSelectModel, models, promptLength })`, plus optional `models` and `modelsByKind` (default: Chai's suggested OpenRouter models per kind, `SUGGESTED_ANALYSIS_MODELS`), `submitting`, `onAbort` (submit becomes stop while submitting; wire to `useMediaAnalyzer().cancel`), `submitError`, `disabled`, `maxAttachmentsByKind` and `onUnsupportedFile`. The instruction text to send a vision model is exported as `MEDIA_ANALYSIS_PROMPTS[kind][length]`. `useMediaAnalyzer()` makes the model call: ```tsx const { submit, analyzing, error, prompt, reset } = useMediaAnalyzer({ engine?: GenerationEngine, // default: ChaiProvider's reasoning engine, then a mock text engine model?: string, // when none is picked; default: the reasoning model for the media's kind onModelChange?, // auto-select: called with the routed analysis model routingPrompt?, onRoutingFallback?, onResult?, onError?, }); ``` It sends the attached media with the instruction for its kind and length (`concise` when none is picked) and returns the generated prompt, which fills in as it streams. A picked model wins over the defaults. Call `reset()` when the media changes; a late reply from an earlier submit is dropped. ### `ResultCard`: one prompt's output ```ts interface ResultCardProps { results: Result[]; // one run's results; paginates when more than one prompt: string; // shown read-only on the flipped info side onAction: (action: string, result: Result) => void; // action: "like" | "dislike" | "retry" | "download" | "share" | "copy-prompt" | one of your moreActions ids models?: ModelOption[]; // resolves modelId to a label getVote?: (result: Result) => "like" | "dislike" | null; // votes are app state moreActions?: { id: string; label: string; icon?: ReactNode }[]; // omit = no ⋯ menu width?: number; // px, default 375 textMaxHeight?: number; // px, default 400; longer text results scroll inside the card disabled?: boolean; onEvent?: (event: ChaiEvent) => void; // front-end analytics, see Analytics below } ``` Renders by `result.status` and `result.output.kind`: - `queued` or `running`: a spinner. - `running` with text output: streaming partial text with a caret. - `done`: the image, video (custom player plus expand), audio or text. - `error`: `error.message` in place. The flip side shows each result's `evaluations` as chips: green if it passed, red if it failed. They come from either or both of two places: - Your backend: your route adds a top-level `evaluations: [{ id, label, passed }]` field to the JSON it returns (for a stream, as its own `data:` chunk). The built-in engines read it. `createChaiHandler` passes responses through unchanged and adds none. - The opt-in judge: `useComposer({ judgeModel, judgeChecks, judgePrompt? })`. A model you name (no default) checks each text or image result on the reasoning engine, one tag per check, after your backend's. Video isn't judged. `judgeChecks: { id, label, criterion, kind?: "text" | "image" }[]` holds the app's own criteria. The default is `DEFAULT_EVALUATION_CHECKS`, one general "Matches prompt" check. The prompt is the exported `buildJudgePrompt`, which `judgePrompt` replaces. Ask the builder what counts as good in their app rather than inventing criteria. The media fills the card. Like, dislike, retry and ⋯ float over its bottom in a light pill, with details (ⓘ) on the right; download, share and expand sit in a dark group at the top. With several results, the page dots are the card's last row, in a bar with the page's model label on the left (from `models`); under an image or video the media fades into a dark bar. Paging slides between results (not for reduced motion). Download, share and copy-prompt work on their own. Retry should re-run the same model call. There is no "use as input" action, by design. ### `EditCard`: precise image edits with marked regions ```tsx const editImage = useCase?.edit ? (attachments.find((a) => a.kind === "image") ?? null) : null; const { run, runs, submit, edit } = useComposer({ engine: createFalEngine({ outputKind: "image" }), editImage }); {edit && } ``` ```ts interface EditCardProps { versions: EditVersion[]; // the original, then each edit; paged with dots in the bottom row activeVersion: number; onActiveVersionChange: (index: number) => void; regions: EditRegion[]; // new regions, each checked in with its instruction onRegionsChange: (next: EditRegion[]) => void; maxRegions?: number; // "New region" turns off at this many; default 6 maxWidth?: number; // px, default 630: the card fills maxWidth × maxHeight, keeping the image's shape maxHeight?: number; // px, default 630 scale?: number; // optional: start from the image's own width (0.3 = 30%), then fit the box width?: number; // optional: a fixed starting width in px, then fit the box alt?: string; onAction?: (action: "download" | "share", version: EditVersion) => void; disabled?: boolean; } interface EditRegion { id: string; number: number; box: { x; y; width; height } /* 0–1 of the image */; prompt: string } ``` - "New region" adds a box with an instruction field; check adds it to the Composer as a colored chip, trash removes it, and clicking a region reopens it. Drag to move, a corner to resize; arrows move and Shift + arrows resize. Zoom keeps the image inside the card; drag to pan. - `Composer` in edit mode (a use case with `edit: true`) drops aspect ratio, shows the region chips, and can submit with regions and no prompt. It needs an attached image. - Each edit's results become new versions; the next edit applies to the version showing. `useComposer` builds the prompt with `buildRegionEditPrompt` (`@chai-ui/core`): boxes for a model with `regionFormat: "flux-3-boxes"`, the regions in words for any other. Override with `editPrompt`. Images only. ### `ChaiProvider`: the shared reasoning model ```tsx > // merged per kind over DEFAULT_REASONING_MODEL_BY_KIND: // video and audio → "google/gemini-3.8-flash" reasoningEngine?: GenerationEngine // default createOpenRouterEngine() > ``` The reasoning models write text: Enhance, auto-select routing, and text requests in `useComposer`. They're separate from the media `engine`. Claude handles text and images; Claude can't read video or audio, so those kinds default to Gemini. All defaults are suggestions; any model id the engine can run works. For lower cost on video and audio, pass `reasoningModelByKind={{ video: "qwen/qwen3.8-omni-flash", audio: "qwen/qwen3.8-omni-flash" }}`. `useReasoning()` returns `{ engine, model, modelByKind }`, or `undefined` outside a provider. `reasoningModelFor(reasoning, attachments)` picks the model for a call: the first attachment kind with its own model, else `model`. Without a provider there are no real reasoning calls: Enhance is hidden and text requests use a mock. ### `useComposer`: the wiring hook ```ts const { run, runs, submit, cancel, enhance, enhancing, routing, edit } = useComposer({ editImage?: DroppedMedia | null, // an image to edit: returns `edit` for an EditCard (see EditCard) editPrompt?: ({ prompt, regions, model }) => string, engine?: GenerationEngine, // default: mockEngine (no key needed) engineByKind?: Partial>, // per use case kind, e.g. image and video Fal engines startImageFit?: "crop" | "pad", // a video's attached start image is fitted to the picked ratio before it's sent; default "crop" textEngine?: GenerationEngine, // text requests; default the reasoning engine, then a mock text engine textModel?: string, // text model when none is picked; default the reasoning model reasoningEngine?: GenerationEngine, // set both to override ChaiProvider's pair; reasoningModel?: string, // used for every kind; `enhance` is undefined with no pair from either enhancePrompt?: (value, mode?) => string, onModelChange?, onModelIdsChange?, // auto-select: called with the routed pick; wire to Composer's model state routingPrompt?: (input) => string, // replaces buildModelRoutingPrompt onRoutingFallback?: (result) => void, onRun?, onResult?, onError?, }); ``` **Auto-select.** With `autoSelectModel` on, `submit` first asks the reasoning model (`ChaiProvider`'s) to pick one model per use case from that use case's `models`, using the prompt and attached media. `routing` is true meanwhile. The pick goes to `onModelChange` / `onModelIdsChange` so Composer's trigger shows it, then the run starts on it. The instruction is `buildModelRoutingPrompt` in `@chai-ui/core`; `routeModel` is the same logic without React. Routing never blocks a request: with no reasoning model, a failed reasoning call (e.g. a bad key) or a reply naming no listed model, it runs on the first listed model and calls `onRoutingFallback` with the reason. With no provider it also logs one console warning. `useMediaAnalyzer` routes the same way, from the media. **Stop.** `cancel()` stops the current submit: wire it to Composer's `onAbort`, with `submitting` true while results are queued or running. Unfinished results turn `"cancelled"` (ResultCard shows "Stopped"; a streamed text result keeps what arrived), and a new submit cancels the previous one. Cancellation is best effort at the provider: Fal removes a queued request and signals a running one, which may still finish; OpenRouter stops a *streaming* request, and its billing, only for providers that support it (not Google); a non-streaming request keeps running. A custom engine receives `signal` in `generate()`: cancel at the provider if it can, then reject with an `AbortError` (`abortError()`). - `submit(payload)` creates one `Run` per selection (one per use case) synchronously, even while auto-select routes (its results read "Choosing model…" until the pick), then updates each `Result` through `queued → running → done | error`. `run` is the first; `runs` is all of them, for multi-use-case select. - Text requests go to `textEngine`; other kinds to `engineByKind[kind]`, then `engine`. - Streaming text engines fill `output` while the result is still `running`. - If a newer submit fires, results from the older runs are dropped. - `enhance` is `undefined` unless both reasoning options are set, so pass it straight to `onEnhance`. ### Primitives - `Toggle`: an MD3 switch, `{ value, onChange, disabled }`. - `SearchMenu`: the searchable option menu behind every picker. Chai's own, not Material's ``: it opens inline from its trigger, below or above (`placement`: "auto" by default, as for Composer's `menuPlacement`), and supports search, sections, a pinned switch and multi-select (`multiple`, with MD3 checkboxes). See DESIGN.md §3. - `Pagination`: `{ count, index, onChange, label, itemLabel, disabled }`. One `slider` control, not a button per dot: a click picks the nearest dot, arrow keys, Home and End move, so the dots sit 16px apart and still meet WCAG 2.5.8. ## Suggested models (`@chai-ui/core`, re-exported from `@chai-ui/react`) Don't write model lists unless the builder wants their own. Composer and MediaAnalyzer use these when no `models` is passed; the first in each is the default pick: - `SUGGESTED_IMAGE_MODELS`, `SUGGESTED_TEXT_TO_VIDEO_MODELS`, `SUGGESTED_IMAGE_TO_VIDEO_MODELS`, `SUGGESTED_EDIT_MODELS` (Fal), and `suggestedModels(useCase)` to get the one for a `GenerationUseCase`. - `SUGGESTED_ANALYSIS_MODELS` (OpenRouter), per media kind. - Each Fal entry has `aspectRatios` and `falInput: { image?, aspectRatio? }` (from its Fal schema), so `createFalEngine` sends each model exactly the fields it takes. Create image and Create video entries also name `withImages`: the endpoint `createFalEngine` calls instead when images are attached (an edit, or image-to-video). Your own models can set it too. - `checkSuggestedModels()` → `{ ok, checked, problems: [{ provider, modelId, problem }] }`: checks the lists against Fal's and OpenRouter's catalogs as they are today. No key, no model run, no cost; works in a browser. Run it while setting up an engine. The lists themselves change only with releases. ## Engines (`@chai-ui/core`, re-exported from `@chai-ui/react`) ```ts interface GenerationEngine { generate(args: { modelId?: string; prompt: string; attachments: DroppedMedia[]; onText?: (textSoFar: string) => void; // streaming engines call this with the accumulated text }): Promise<{ src: string; kind: MediaKind; usage?: Usage; evaluations?: Evaluation[] }>; } ``` - `createFalEngine({ outputKind?: "image" | "video", model?, modelByKind?, endpoint? })` generates media through Fal's queue API. This is the default engine. It returns no `usage`, because Fal bills per generation. - `createOpenRouterEngine({ model?, modelByKind?, maxTokens?, pricePerMillionTokens?, endpoint? })` returns text and can reason about attached images, video or audio. It streams when `onText` is passed. It is fully supported, with one caveat: OpenRouter has no video-generation models and only a thin audio lineup. - `createVercelEngine({ outputKind?: "text" | "image", model?, modelByKind?, maxTokens?, pricePerMillionTokens?, endpoint? })` calls Vercel's AI Gateway (one OpenAI-compatible endpoint for many labs' models). Text streams through `onText` like OpenRouter's; image output uses the gateway's images endpoint and takes a prompt only. There is no built-in model: name one. The gateway has no video-generation endpoint, so use Fal.ai for video. - `createMockEngine({ outputKind?: "image" | "text", failureRate? })` and `mockEngine` return placeholder output with no network call. - **Custom engines:** implement `generate`, and throw `GenerationError(message, reasons?)` for failures users should see. **Keys never go in the browser.** The built-in engines call one same-origin route, `/api/chai/...` (`/api/chai/fal/...`, `/api/chai/openrouter/chat` and `/api/chai/vercel/{chat,images}`). Serve it with `createChaiHandler` from `@chai-ui/core/server`, which adds the key and streams the reply through: ```ts // Next.js: app/api/chai/[...path]/route.ts import { createChaiHandler } from "@chai-ui/core/server"; export const { GET, POST, PUT } = createChaiHandler(); ``` - Keys default to the `FAL_KEY`, `OPENROUTER_API_KEY` and `AI_GATEWAY_API_KEY` environment variables (on Vercel, `VERCEL_OIDC_TOKEN` also works); pass `falKey` / `openRouterKey` / `aiGatewayKey` where there's no `process.env` (e.g. Cloudflare Workers). - `handle(request)` takes a web `Request` and returns a `Response` (Next.js, React Router or Remix, Hono, Cloudflare, Bun, Deno). Nuxt: `server/api/chai/[...path].ts` exporting `defineEventHandler((event) => chai.handle(toWebRequest(event)))`. `toNodeHandler(handler)` adapts it for Express and plain Node: `app.use("/api/chai", toNodeHandler(createChaiHandler()))`. - Production: `authorize: (request) => boolean | Promise` rejects with 403, and `allowedModels: string[]` rejects any other model with 403. Without them, anyone who reaches the site can spend the key. - Errors come back in each engine's own shape, e.g. "FAL_KEY isn't set on the server." Never import `@chai-ui/core/server` in browser code. ## Analytics (`onEvent`) `Composer` and `ResultCard` take `onEvent?: (event: ChaiEvent) => void` (`@event` in Vue). Each event is flat, `{ name, properties }` with string, number or boolean values, so one function serves every component and any tool: ```tsx const track = (e: ChaiEvent) => posthog.capture(e.name, e.properties); ``` No Chai package imports an analytics library. Events are front end only: what the person did and was shown. No prompt text, file names, URLs or outputs are sent, only lengths, counts and Chai's own ids. Model call success, latency, tokens and cost belong to the builder's back end. - `chai_composer_submitted`: `use_cases`, `model_count`, `auto_select`, `aspect_ratio?`, `attachment_count`, `prompt_length`, `retry` - `chai_composer_stopped`, `chai_composer_voice_started`: no properties - `chai_composer_use_case_picked`: `action`; `chai_composer_use_case_removed`: `use_case` - `chai_composer_media_attached`: `count`, `unsupported_count` - `chai_composer_model_picked`: `use_case`, `model_id` - `chai_composer_auto_select_toggled`: `on` - `chai_composer_option_picked`: `option` (`aspect_ratio`, `voice`, `genre`, `frame_rate`), `value` - `chai_composer_enhance_clicked`: `field`, `prompt_length`; `chai_composer_enhance_reverted`: `field` - `chai_result_shown` (once per result): `status` (`done` or `error`), `output_kind?`, `result_count` - `chai_result_action`: `action`; `chai_result_paged`: `index`, `count`; `chai_result_info_opened` The `ChaiEvent` type and the builders `composerEvents` and `resultCardEvents` are exported from `@chai-ui/core` and re-exported by `@chai-ui/react` and `@chai-ui/vue`. Setup: https://chai-ui.com/docs/analytics ## Data model ```ts Request { mode?: GenerationUseCase; prompt; attachments; modelIds: string[]; params; presetId? } Run { id; request; createdAt; results: Result[] } Result { id; runId; modelId; status: "queued" | "running" | "done" | "error"; output?: { src; kind }; usage?: Usage; evaluations?: Evaluation[]; error?: { message; reasons? }; durationMs? } Evaluation { id: string; label: string; passed: boolean } // a verdict from YOUR backend's check ``` Pure transitions are exported from `@chai-ui/core` for custom stores: `createRun`, `startResult`, `streamResult`, `resolveResult`, `failResult` and `deriveGenerationUseCase`. ## Accessibility Chai's components are built to support WCAG 2.2 AA and are tested against it with automated checks: axe (WCAG 2.0, 2.1 and 2.2, levels A and AA) on every component state in the test suite, plus keyboard and focus tests in a browser. They haven't been tested by hand with a screen reader, so this is support, not a certification. Compliance is judged for a whole app, so test yours, including with a screen reader. `Composer`'s `promptLabel` is the prompt field's accessible name (default: `placeholder`). `ResultCard`'s `altText?: (result) => string` sets image alt text (default: the prompt). `Toggle` takes `ariaLabel`. Interactive targets are at least 24×24 px. ## Focus rings On by default: every CHAI control shows a keyboard-focus ring, as WCAG 2.2 AA (2.4.7) requires. `data-chai-focus-ring="hidden"` on any ancestor turns them off for the CHAI components inside it. Keep the default in production apps: hiding them breaks WCAG compliance. Only hide them on showcase or preview surfaces. ## Tokens (`@chai-ui/tokens`) `@chai-ui/tokens` is the only source of color: no component has a hard-coded color, and the Material components' `--md-sys-color-*` roles point at Chai tokens. CSS variables, all prefixed `--chai-`: - `color-semantic-accent*`, `text-on-accent`, `warning*`, `danger*`: brand green (`#009747`) and states. Use `accent-text` for green text. - `color-semantic-control-*`: the grays the components are built from. - `color-semantic-{overlay, on-overlay, overlay-light, scrim, media-backdrop, on-media-fade}`. - `color-semantic-region-1` … `region-6`: the edit card's region colors. - `space-0` … `space-8`, `radius-{sm,md,lg,full}`, `type-*`, `motion-*`, `shadow-menu`, `gradient-{ai, media-fade}`. - Component variables `--chai-composer-*` and `--chai-media-analyzer-*`. Don't use `--chai-color-root-*` or `--md-sys-color-*` directly. The full list is in `@chai-ui/tokens/css` and in DESIGN.md §4. Don't reference any other variable name. ## Not built yet (don't assume these exist) - A `Results` grid or `Compare` component: one `ResultCard` pages through a prompt's results (`runs.flatMap((r) => r.results)`); with Image and Video together, use one `ResultCard` per run. No `MediaViewer`. - Separate `ModelPicker`, `ParamControls`, `Presets` or `RunSummary` components - `createHttpEngine` - Cost estimates before a run - Inline citations. There are deliberately no confidence scores: quality checks are pass or fail, from your backend or the opt-in judge, in `Result.evaluations`. - Sound effects, or a duration control for music (Create speech and Create music exist) When asked for one of these, say it isn't in CHAI yet and build it in the app. Don't fabricate a CHAI component. ## More - `DESIGN.md` (in the package): usage rules, tokens, states, accessibility. - Repo `README.md` and `ROADMAP.md`: what's built and what's next. - Figma: the Chai UI Kit, every component in its states with the tokens as Variables, https://www.figma.com/community/file/1696038805378975948