@prima/ui package
ui-design-system/ui/prima-ui-package
| Kind | frontend |
|---|---|
| Surface | chat |
| Maturity | prod |
| Reusable | yes |
| Design system | prima |
| Stack | typescript-4.9, react-18-or-19, plain-css, node-test |
| Depends on | _shared/design-system-and-tokens |
| Renders | no backend module: a design-system row |
| Entry | prima/packages/ui/src/react/index.ts |
| Reference implementation |
|
| Measured on | prima feature/per-model-pricing cd2c248 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
Prima's conversation frontend, shipped once as a package so the chat web app, a panel inside a signed-in app and a native shell render the same conversation, the same model picker, the same usage meter and the same layered-job view instead of three half-versions. Two invariants make it trustworthy and both are tested: the package never holds a credential (the host supplies a PrimaTransport; there is nowhere to put a key), and it never executes a tool (it renders what the model asked for, hands the call to the host, and renders the result; with no executor the call shows "not executed" and nothing runs).
It makes the AI gateway, the model catalogue, the usage meter and the layered-job runtime visible; those backend recipes (domains ai-gateway and agent-runtime, decision D3: the standalone prima repo is the reference) name this row as their frontend.
When to use
Use it on any surface that shows a Prima conversation: the chat web app (chat, PrimaProvider with chrome="standalone"), a panel inside a signed-in app (chrome="embedded"), a native shell (which builds on core/ and leaves react/ alone). Trigger phrases: "the chat UI", "model picker", "show the layered job", "tokens used", "the tool call card".
Do not use it for the marketing site, the developer portal's docs, or any page without a conversation: those are prima.li Astro components. Do not build a second conversation view; a host that needs a different look overrides .prima-root custom properties and keeps the components.
Stack assumptions
S1's prima design system, with the package's own token layer on .prima-root (S1 Data model). Pinned as measured in prima/packages/ui/package.json at cd2c248 (2026-09-11): version 0.1.0, typescript ^4.9.5 (strict, react-jsx, matched to the Labs compiler on purpose so Labs can consume the package as source through a Vite alias and typecheck it itself; README "Development". Measured 2026-09-11: the Labs frontend at fd8558f has no such alias and no import of @prima/ui; see Gotchas), react >=18 and react-dom >=18 as optional peers, vite ^6.4.3 for the demo only. core/ has zero dependencies and no DOM. Styling is one plain CSS file with every class prefixed prima-: not Tailwind, not CSS-in-JS, not modules, because three hosts with three build systems have to render the same component (styles.css lines 1 to 19).
Data-fetch style: the host's. httpTransport({ baseUrl, fetchImpl }) (transport.ts line 52) takes the host's authenticated fetch; baseUrl is the host's server-side proxy (/api/prima-gateway/v1 in Labs; the chat app's own /api routes), never the gateway with a key in the browser. Copy language: English; the chat host localises around it.
Data model
Client state per PrimaProvider:
Conversation(core/conversation.tsline 81):TurnEntry[](key,role,content,alias,requestId,usage,toolCalls: ToolCallView[]),ConversationState(busy, error), bounded tool rounds and calls per round (ConversationOptions, line 62).StreamAccumulator(core/accumulator.ts): folds SSE deltas into one message; normalisestool_calls[].function.argumentsto a JSON string whether the gateway sent a string (streaming) or an object (non-streaming); survives the"id": nullcontinuation fragment.ModelCataloguefromgroupModels(/models)(core/models.ts): floating aliases versus pinned-2026-08variants, split by the self-description fields only; no alias literal exists in the package (the test "the package contains no hardcoded model alias" walks every.ts,.tsxand.css).JobWatcher(core/job.ts): keeps the first fullGET /jobs/{id}record (provenance and trace are served exactly once), counts affinity misses (job_not_here), exposesdegradedandtraceLost.UsageResponserendered as counts only;format.tshas no cost estimate (README:max_tokensdoes not bound billed tokens; measured 16 requested, 115 billed).- Host seam (
core/host.ts):PrimaHost { name, tools?, executeTool?, confirmTool? }(line 57),HostToolCall(32),HostToolResult(41),ToolCallPhase = 'requested' | 'awaiting-confirmation' | 'executing' | 'settled' | 'unhandled'(99).
Browser storage: none in the package. The chat host keeps its own session and repository selection outside it.
API surface
Consumed through the host's transport, all under the gateway's OpenAI-shaped prefix (/api/prima/v1 on the gateway; the host proxies it): POST /chat/completions (streaming SSE and non-streaming), GET /models (the catalogue; authenticated; publishes no pricing), GET /usage (counts per bucket), POST /jobs and GET /jobs/{id} (layered runs; the first GET after completion carries provenance and trace and the server then drops them). Errors arrive as the gateway's { error: { message, type, param, code, request_id } } envelope and surface as PrimaError (transport.ts line 80), rendered by .prima-banner--error with the message as copy; a 404 job_not_here is a miss, not an error (JobWatcher keeps polling).
Tool calls are a contract with the host, not the server: the package emits HostToolCall, the host returns HostToolResult, the package sends the result back to the model. executeTool must not call back into Prima (README "The host": Host to Prima to Host, unbounded, each level spending tokens); the package bounds rounds and calls per round and cannot see inside a handler.
Application logic
- The host mounts
PrimaProviderwith a transport, a host object and a chrome, and imports@prima/ui/styles.cssonce. ConversationViewmounts:useModelsloads the catalogue for the picker;useUsage(60_000)polls the meter whenshowUsage(default: on instandalone); the empty state renders until the first turn.- The user sends:
ComposercallsuseConversation().send; the request streams;StreamAccumulatorfolds deltas into the assistant entry;Messagerenders content throughrenderContent(the host's markdown) or as text; the alias chip, token count and request id render under it. - The model asks for a tool:
ToolCallCardrenders the name and arguments ("The model asked for"). WithconfirmToolthe phase isawaiting-confirmationuntil the host answers; refused means not executed and the model is told. WithexecuteToolthe host runs it and the result renders ("The host did"); the result goes back to the model for the next round. With neither, the card reads "not executed" and the model receives no result. LayeredJobView(a separate mount,useLayeredJob): shows status and elapsed time while the job runs, then the caput mortuum first (unverified claims with reasons), the four opus stages in order (stages that did not run are kept and marked), provenance, and the trace table;traceLostrenders as a sentence rather than an empty run.UsageMeterrenders counts and buckets with a bar; an over-allowance bucket is flagged;usageWarning()explains when requested and billed tokens disagree.
Invariants
- No credential in the package: nothing reads a key, a cookie or storage;
httpTransporttakes the host's fetch. - No tool executes inside the package; no executor means "not executed", never a hang and never a silent run (tests at
core.test.cjslines 423, 442, 489). - The tool loop is bounded (line 514).
ChatMessage.contentisstring | null(types.tsline 90): the core does not model vision blocks yet, which blocks image input by construction until the type changes.- No model name in the package (line 35); no cost estimate anywhere (line 554).
- The layered trace is kept from the first full record and never overwritten by a later counters-only body (line 318).
- Theming is by custom property on
.prima-root; a component is never forked for a host. - Both colour schemes are defined (
prefers-color-schemeplus[data-theme]overrides,styles.csslines 64 to 121); inside a light-locked host the light scheme applies.
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
The live rebuild for this recipe has not landed. 2026-09-11: packages/prima-ui (the @prima/ui core, react and styles.css extracted from prima cd2c248) does not exist in this repo yet; RENDERING.md section 4, prima-ui-package row, and step M6
Component tree, from the recipe
PrimaProvider (transport, host, chrome: standalone | embedded)
.prima-root[.prima-root--standalone | --embedded] custom properties: --prima-bg, --prima-fg, --prima-accent, ...
ConversationView (title, showUsage, renderContent)
.prima-head title, ModelPicker (select of floating aliases + "Pin" checkbox), UsageMeter (compact)
.prima-scroll Message x TurnEntry
.prima-msg--user "YOU" label, bubble
.prima-msg--assistant "PRIMA" label, content (renderContent or text), alias chip, token count, request id
ToolCallCard x toolCalls (.prima-tool: name, phase, "returned <ms>" | "not executed" | "failed";
"THE MODEL ASKED FOR" arguments, "THE HARNESS DID" result)
.prima-banner error | warn (PrimaError message)
.prima-empty first-run sentence
Composer textarea, send, cancel (busy)
UsageMeter (full) .prima-usage: counts per bucket, .prima-bar, over-allowance flag, no cost
LayeredJobView .prima-job: status, elapsed, .prima-caput (caput mortuum with reasons), .prima-claims, .prima-stage x 4 (opus), .prima-evidence, .prima-facts, .prima-table (trace) The rendering will be the live demo prima-ui-package/demo.tsx: the package's own fixture page (demo/ssr.tsx at cd2c248, which renders every component from test/fixtures/) split into states and built from @prima-modules/prima-ui/react with @prima-modules/prima-tokens's bridge for the accent, rendered by the docs host as one frame per state (RENDERING.md, section 3). That package does not exist in this repo on 2026-09-11: @prima/ui is private: true with no registry and the prima repo has no remote (inventory D7), so the demo cannot import it until packages/prima-ui is extracted the way packages/alchemy-ui was (RENDERING.md, section 4, the prima-ui-package row, and step M6). The frontmatter therefore carries demo: null with demo_blocked_by naming that step, and the docs page renders the blocker sentence in its unset register instead of a picture. The rule that a component the packages lack is added to a package from its source and never written in a demo folder (RENDERING.md, section 6) is why there is no interim rebuild here.
When the package lands, the demo's states are: picker (ModelPicker twice: a floating alias unpinned, then the pinned variant ticked), conversation (Message for a user turn and a folded assistant stream with the alias chip, the token count and a request id, then Composer idle), tool-calls (a get_weather call the host executed, "returned 412ms", and a publish_page call on a surface that cannot execute, "not executed", "The model asked for", "The harness did: Nothing"), usage (UsageMeter with counts per bucket and the embeddings bucket flagged over its allowance, no cost anywhere), layered-job (LayeredJobView for a succeeded run: the caput mortuum first with its reasons, the four opus stages, provenance and the trace table). Not rendered: streaming in progress, the error banner, the empty state, the confirmation prompt, the dark scheme. Sample data beside the demo has the fixtures' shapes with fictional values: the model list carries the same eight aliases as models.json, the usage figures and the layered record keep their fields, and every id, tenant and request identifier is a placeholder, because the fixtures captured from the live gateway carry real identifiers and nothing real ships in this kit. Nothing is fetched: the transport is a stub that never answers. rendering.verify is then route: /render.html, session: anon, selector: 'section:nth-of-type(1)' against the package's demo/ served statically.
Tokens on the frames: through the bridge, --prima-accent is the host's --fg-accent (prima.li's violet on the docs host, where the package default is the gold #8a6a1f), --prima-fg, --prima-fg-muted, --prima-bg, --prima-bg-raised and --prima-line follow the host's aliases; --prima-bg-sunk for user bubbles, --prima-ok on the settled tool call and --prima-mono for names and ids stay the package's own. A host rendering (the chat app) overrides --prima-accent and --prima-radius in the same way (prima-chat/web/src/styles.css lines 1851 to 1853) and restyles the picker label with its own font (lines 1862 to 1869). Responsive: the root is a flex column; at 390 px the picker rows stack.
Security & tenancy
The browser holds no key: the transport is the host's authenticated fetch to the host's proxy, which attaches the credential server-side (the demo's dev proxy does the same with PRIMA_KEY read in the Vite server, demo/vite.config.ts lines 3 to 10; the key never reaches the bundle). Auth on the gateway is X-API-Key, never Bearer; sending both was the cause of a 401 (comment, demo/vite.config.ts lines 29 to 31). Tool execution is the host's and is gated by confirmTool for externally visible effects; the package cannot bypass the gate. Content the model returns is rendered through the host's renderContent (the chat app sanitises with DOMPurify, prima-chat/package.json) or as text. Surface gating from /me.surfaces is the host's concern; the package has no notion of a user.
Tests
npm testinprima/packages/ui:tsc -p tsconfig.test.jsonthennode --test test/core.test.cjs; 19 tests, no network,19 pass, 0 failon 2026-09-11 in 0.1 s. They cover: no hardcoded alias (walks the source tree),groupModels,partitionClaimsand the caput mortuum ordering,stageViews, SSE folding incl. a frame split across reads and the string-versus-object arguments,JobWatcher(record kept,traceLost,job_not_hereas a miss), the host seam (no executor renders and nothing runs; the host runs it; a refused confirmation; the bounded loop), usage as counts only and the missing cost estimate.- The SSR renderer (
demo/ssr.tsx) is the package's render check: it throws if a component cannot render from the fixtures. Once the demo exists, the kit's render smoke (node tooling/verify/render-smoke.mjs) renders its five states withrenderToString, andtooling/verify/compare.mjs --state pickeragainstdemo/render.htmlserved statically is the visual check, whose report is never committed. Until then the kit renders nothing for this row. - The chat host's
npm run render-check(prima-chat/scripts/render.mjs) bundles the real app and mounts it in jsdom withfetchstubbed, twice (with and without a repository), and asserts on the DOM; it says nothing about the gateway (scripts/smoke.mjsdoes, against the real one). - No Playwright gate in the package; the hosts carry theirs.
Deploy & ops
npm run build (tsc -p tsconfig.build.json) writes dist/core and dist/react; package.json exports . (core), ./react, ./styles.css (from src/, unbuilt) and ./src/*. Hosts consume it by relative path: the chat app's package.json has "@prima/ui": "file:../prima/packages/ui" and its Dockerfile copies prima/packages/ui/ into the image, runs npm ci and npm run build there, then builds the app (e606308, lines 26 to 43); Labs aliases the source. No env in the package; PRIMA_KEY and PRIMA_UPSTREAM exist for the dev harness only. CSP on a host: the transport is same-origin to the host's proxy; no third-party font or style. Rollback is the host's.
Gotchas
- 2026-09-11 (measured): the default
--prima-accentis gold (styles.cssline 32). A host that forgets the override ships gold controls; the chat app overrides it in.picker-slotonly (line 1851), which is the one place it mounts a package component. - 2026-09-11 (measured,
demo/ssr.tsxlines 143 to 150): the rendered page's preamble names the host the fixtures were captured from, andtest/fixtures/usage.jsoncarries a real tenant id. Neither is in this kit: the demo's sample data repeats the shapes with placeholders, and the verifier clips the app capture to the first section (verify.selector). - 2026-09-11 (measured): the README's "three hosts" table names Labs as a consumer through a Vite alias; at fd8558f the Labs frontend has no alias to
packages/uiand imports nothing from@prima/ui(its slide-over is Labs' own component). The chat app importscore/andModelPickeronly. The fullConversationViewrenders today in the demo page, which is why the demo is the rendering. - 2026-09-11 (measured,
types.tsline 90):content: string | nullkeeps vision out of the core; adding image input is a type change plus an accumulator change, not a component tweak. - 2026-08-03 (measured, README): a
GET /jobs/{id}can hit a worker that never saw the job;JobWatchertreats it as a miss and keeps polling, but only ingress hashing onjob_idfixes the server side. - 2026-08-03 (measured, README): tool-call arguments were a string on the streaming path and an object on the non-streaming path; the accumulator normalises to the string, because the string is what has to be sent back.
- 2026-09-11 (measured): the
primacheckout carries an untracked.claude/directory, so the sidecar saysdirty: true; nothing underpackages/uiwas modified (the build writes only gitignoreddemo/dist-ssr/anddemo/render.html). - 2026-09-11 (measured): the repo has no remote (inventory D7); the recipe cites the branch and commit, and a customer clone of the package waits on the remote decision.
Reference implementation
Branch that ships: prima feature/per-model-pricing at cd2c248 (2026-09-11; no remote). Paths relative to packages/ui/:
src/core/index.ts(barrel),types.ts(every wire type;contentat 90),transport.ts(PrimaTransport32,httpTransport52,PrimaError80),client.ts(PrimaClient21, one method per endpoint),sse.ts,accumulator.ts,models.ts,opus.ts,job.ts,conversation.ts(TurnEntry32,Conversation81),host.ts(HostToolCall32,PrimaHost57,toHostToolCall89,ToolCallPhase99),format.ts.src/react/index.ts(exports),context.tsx(PrimaProvider,usePrima),hooks.ts(useModels,useUsage,useConversation,useLayeredJob),components/ConversationView.tsx(props 26 to 36),Message.tsx,Composer.tsx,ModelPicker.tsx,ToolCallCard.tsx,UsageMeter.tsx,LayeredJobView.tsx(also exportsCaputMortuum).src/styles.css(922 lines):.prima-roottokens 21 to 62, schemes 64 to 121, then.prima-head,.prima-picker,.prima-scroll,.prima-msg,.prima-thinking,.prima-tool,.prima-composer,.prima-banner,.prima-empty,.prima-usage,.prima-bar,.prima-job,.prima-section,.prima-badge,.prima-caput,.prima-claims,.prima-verdict,.prima-evidence,.prima-stage,.prima-facts,.prima-table,.prima-gaps.demo/ssr.tsx(the rendering),demo/ssr.config.ts,demo/main.tsxanddemo/vite.config.ts(the live harness with the server-side key),test/core.test.cjs(19 tests),test/fixtures/.README.md: "Layout", "Using it", "Theming", "The host", "The three hosts", "Things that will bite".- Host:
prima-chat/web/src/App.tsxlines 9 to 11 (importsgroupModels,defaultChatModel,formatTokens,ModelPicker),web/src/lib/session.ts(PrimaClient,StreamAccumulator,PrimaHost),web/src/lib/host.ts,web/src/styles.css.picker-slot(1851 onward),Dockerfile(26 to 43).
Deviations of the reference from this recipe, dated 2026-09-11: the gold default accent; the chat host renders its own message list and composer and mounts only ModelPicker from react/ (it uses core/ for the rest); Labs does not consume the package at fd8558f despite the README; no remote on the repo.
Acceptance checks
From prima/packages/ui at cd2c248:
npm testprints# pass 19and# fail 0.grep -rn "api_key\|X-API-Key\|localStorage" srcprints one line, the comment atsrc/core/transport.tsline 21 (the key handling is indemo/vite.config.tsand in hosts only).grep -c "^ --prima-" <(sed -n 21,62p src/styles.css)prints25;grep -c "^\.prima-" src/styles.cssprints148.npx vite build --config demo/ssr.config.ts && node demo/dist-ssr/ssr.jsprintswrote .../demo/render.html; the file containsprima-picker,prima-tool--unhandled,prima-usageandprima-caput, and does not containsk-orX-API-Key.- Browser walk-through on the rendered page: the second picker's "Pin" is ticked and shows the pinned variant name; the
get_weathercard shows "returned" with a duration; thepublish_pagecard shows "not executed" and a result line saying nothing ran; the usage section shows counts and no currency; the layered section lists the caput mortuum before the stages. - Demo gate:
npm run checkprints no line for this recipe (demo: nullis paired with a dateddemo_blocked_by, and noscreenshotkey remains);grep -c "demo_blocked_by" modules/ui-design-system/ui/prima-ui-package.mdprints1untilpackages/prima-uiexists. - When the demo lands:
node tooling/verify/render-smoke.mjsrenders the five states; servedemo/statically (any static server on 127.0.0.1) and runnode tooling/verify/compare.mjs ui-design-system/ui/prima-ui-package --state picker --docs http://127.0.0.1:4321 --app http://127.0.0.1:<port>; the report haspage_errors: [], and the verifier readsmismatch_ratioknowing the accent differs by design (gold on the package page, the host's violet through the bridge on the docs host).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.