Prima Get an API key

Module kit / Identity and auth

Chat sign-in and workspace picker

identity-auth/ui/chat-signin-and-workspace-pickerlive

Kind frontend
Surface chat
Maturity prod
Reusable extract
Design system prima
Stack react-19, typescript, express-4, node-22, prima-tokens
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders identity-auth/prima-chat-session-and-gateway-key, identity-auth/magic-link-login, identity-auth/gateway-api-key-auth, identity-auth/surface-registry-and-gate
Entry prima-chat/web/src/components/SignIn.tsx
Reference implementation
  • prima-chat/web/src/components/SignIn.tsx
  • prima-chat/web/src/components/WorkspacePicker.tsx
  • prima-chat/web/src/components/SparkMark.tsx
  • prima-chat/web/src/lib/auth.ts
  • prima-chat/web/src/lib/workspaces.ts
  • prima-chat/web/src/App.tsx
  • prima-chat/web/src/styles.css
  • prima-chat/server/auth.ts
  • prima-chat/server/routes/workspaces.ts
  • prima-chat/server/sessions.ts
Measured on prima-chat w5-billing-panel 743b840 2026-09-12
Also measured on
  • alchemy_labs d1a-token-source fd8558f 2026-09-12
  • prima.li w8b-content-source 86fe9d1 2026-09-12
Version 1

Purpose

A person types an address, gets told "if there is an account, a link is on its way", opens the link and is in; if their account belongs to several workspaces they are asked once which one the usage is billed to, with owned workspaces first and a search box when the list is long; the rail then shows who they are, which workspace is billed and that a gateway key is spending, and "Sign out" ends it. The invariant that makes it trustworthy is that the browser never holds a credential or a tenant: the sign-in screen sends an address and a code and receives an opaque cookie, the picker sends one workspace id over a same-origin POST and receives a projection, and every sentence on these screens is either the client's own copy or a sentence the chat server wrote, never an upstream body.

It makes visible identity-auth/prima-chat-session-and-gateway-key (B21: the five auth routes, the two workspace routes, WorkspaceState, the sign-out report), identity-auth/magic-link-login (B5: the 202 that says nothing, the one 400 for expired, used and forged), identity-auth/gateway-api-key-auth (B11: the key prefix line and the "no gateway key" panel), and identity-auth/surface-registry-and-gate (B4: the surface_denied sentence when an account holds no prima_chat grant).

When to use

Trigger phrases: "the chat login", "no password on the chat", "I already have a code", "check your inbox", "choose a workspace", "switch workspace", "whose usage is this", "no gateway key", "workspaces unavailable", "why does sign-out say 404". Surface: chat, because these screens belong to the Prima chat client (prima-chat/web, a React 19 Vite app served by its own Express server), are styled by the chat's own stylesheet in the Prima palette (D1b: Prima tokens for Prima surfaces), and talk only to the chat server's same-origin /api/auth/* and /api/workspaces routes, never to Labs directly.

Not this recipe: the Labs login (identity-auth/ui/auth-screens, F1: passwords and passkeys exist there), the developer portal's sign-in (identity-auth/ui/developer-portal-auth-pages, F7: the same magic link, the token in sessionStorage), the chat's billing panel and top-up (billing-credits/ui/chat-billing-panel), the conversation UI the rail belongs to (ai-gateway/ui/chat-conversation-ui), and the Labs workspace switcher (identity-auth/ui/workspace-switcher-and-bootstrap, F2: a different app, a header the browser sends).

Stack assumptions

Pinned at 743b840 (prima-chat/package.json): react ^19.2.8, react-dom ^19.2.8, express ^4.21.2, vite ^6.4.3, typescript ^5.7.2, Node 22 (@types/node ^22.10.0); @prima/ui by file path for the model picker, not used by these screens. S1's shared layer is assumed as the Prima palette: the chat declares its own :root (web/src/styles.css 77 to 215, its comment at 78 says "verbatim from prima.li/src/styles/global.css") and reads no token package, so @prima-modules/prima-tokens is what the demo binds to and the chat is what a customer's build would migrate onto (Gotchas). S2 does not apply: there is no api singleton, no authedFetch, no token storage; the client half is two files of fetch with credentials: 'same-origin' and Accept: application/json (web/src/lib/auth.ts 123 to 141, web/src/lib/workspaces.ts 36 to 50). Copy language: English on every screen (en), including the picker's role labels; the German collation in rankWorkspaces is for names, not copy. No component library: the markup is plain elements with BEM-style classes (signin__*, wsp__*, rail__*, btn, btn--primary, btn--ghost), and the mark is an inline SVG with stroke="currentColor" so the same component recolours on the ink band and the light card (SparkMark.tsx header).

Data model

Client state, per screen:

// SignIn.tsx 33 to 41
phase: 'email' | 'sent' | 'code';   email: string;   code: string;   busy: boolean;
error: string | null;                // a complete sentence from lib/auth.ts, or the initialError of a failed ?token=
retryAfter?: number;                 // the 429's Retry-After, appended as "(<n>s)"

// WorkspacePicker.tsx 69 to 83
query: string;   active: string | null;   busy: boolean;   failure: string | null;   refreshing: boolean;
showList: boolean;                   // "Choose another" out of the key-failed panel (74 to 81)

// Root and App (App.tsx 64 to 65, 200 to 202, 1054 to 1063)
session: SessionUser | null | undefined;   // undefined until currentSession() answers; null is signed out
linkError: string | null;   switching: WorkspaceDirectory | null;   notice: { kind, text } | null

The projection the browser holds (web/src/lib/auth.ts 26 to 83, mirrored from server/sessions.ts::publicSession 877 to 921): SessionUser { subjectId, email, name, surface, createdAt, expiresAt, gateway: { credential: 'key' | 'none', keyPrefix?, tenantId?, scopes?, error? }, workspace: ChosenWorkspace | null, workspaceState, workspaces: Workspace[] | null, workspacesError }; Workspace { id, name, slug?, role, isOwner }; ChosenWorkspace adds chosenAt; WorkspaceState = 'ready' | 'choose' | 'none' | 'unavailable' | 'key_failed'. The comment at 69 to 78 is the rule: workspace is a projection, "a tenant id the browser can set is an accounting record the browser can set". SignOutReport { local, gatewayKey: {attempted, ok, status, note?}, retiredKeys: {attempted, revoked}, upstreamSession: {...} } (261 to 269).

The demo's props (demo.tsx): { screen: 'signin' | 'picker' | 'account', signin: SignInProps, picker: PickerProps, account: AccountProps }, where SignInProps is the component's state above, PickerProps is the component's props (workspaces, selected, state, error, keyError, dismissible), and AccountProps is the rail block's inputs plus the report.

Browser storage: none. No localStorage, no sessionStorage, no token in memory (lib/auth.ts 20 to 23: "there is no saveSession, no readSession, no authHeader, deliberately"). The one thing the browser holds is the __Host-prima_chat_sid cookie (prima_chat_sid without TLS), HttpOnly, SameSite=Lax, Path=/, which JavaScript cannot read (B21). The ?token= of a magic link is taken out of location.search and scrubbed from history with replaceState before anything else runs (takeTokenFromUrl, 300 to 313).

API surface

Contracts consumed, all same-origin on the chat server (B21 API surface), with the sentence each code becomes:

Client function (line)RouteCodes and the copy
requestMagicLink(email) (lib/auth.ts 177 to 203)POST /api/auth/magic-link {email}client-side invalid_email "Enter a valid email address." before any request; 202 {ok: true} whether or not the address exists, rendered as the sent phase; 429 "Too many attempts. Wait seconds." or "... minutes." with retryAfter from Retry-After; 403 surface_denied and 502 server render the server's own error sentence; a thrown fetch "The sign-in service is unreachable. Check your connection and try again."
verify(token) (212 to 244)POST /api/auth/verify {token}client-side missing_token "This link has no sign-in code. Open the link straight from the email."; 400 and 401 collapse to link_invalid "This link has expired, has already been used, or is invalid. Request a new one." (the server already collapses upstream 400, 401 and 404, B21); 429 "Too many attempts. Wait a few minutes."; 200 {session} sets the cookie and hands the projection to onSignedIn; a 200 without session is "The sign-in service returned no session."
currentSession() (247 to 259)GET /api/auth/session200 {session} or {session: null, reason}; any failure is null, which is the signed-out screen, never an error
listWorkspaces(refresh) (lib/workspaces.ts 59 to 80)GET /api/workspaces?refresh=1200 {workspaces, selected, state, error}; any non-2xx becomes state: 'unavailable' with the client's own reason {reason: 'network', message: 'Your workspaces could not be loaded. Try again.'}
selectWorkspace(id) (83 to 110)POST /api/workspaces/select {workspaceId}200 {ok, session, previousKey}; 400 missing_workspace, 403 unknown_workspace ("not yours" and "does not exist" are one answer, routes/workspaces.ts 80 to 87), 409 no_workspaces, 503 directory_unavailable, 502 mint_failed: the body's error sentence is shown in p.wsp__error, else "The workspace could not be set."; a throw is "The server is unreachable. Try again."
signOut(everywhere) (283 to 290)POST /api/auth/logout {everywhere}200 the report; the client reloads whatever it says (App.tsx 831 to 845), and logs gatewayKey.ok and upstreamSession.status to the console when the upstream half failed

What the screens never read: requireUser's refusals (not_signed_in, session_unknown, session_revoked, session_expired, session_revoked_upstream, identity_provider_unavailable; server/auth.ts 100 to 115) reach currentSession as a non-2xx and become the sign-in screen with no sentence; requireWorkspace's 409 workspace_required, 409 no_workspace, 503 workspace_directory_unavailable and 502 gateway_key_unavailable (154 to 192) are answered on the spending routes only and are what the conversation UI shows, not these screens (the gate keeps App unmounted until ready, App.tsx 97 to 125). Error bodies: the chat server's {error: <sentence>, code: <token>} throughout (S7 Frontend: the chat client reads a sentence as is and keys on code); no Labs envelope reaches the browser from these routes.

Application logic

  1. Root mounts with session: undefined and shows the "One moment…" card (App.tsx 84 to 93). Its effect takes ?token= out of the URL; if present, verify(token): success sets the session, failure sets linkError and falls through to currentSession() (69 to 82).
  2. No session: SignIn with initialError (95). The screen opens in the code phase when a link failed, else email (SignIn.tsx 36).
  3. Email phase: typing enables "Send sign-in link"; submit sets busy ("One moment…"), calls requestMagicLink; ok moves to sent, else the sentence and retryAfter land in error (43 to 62). "I already have a code" jumps to code.
  4. Sent phase: "If there is an account for
    , a sign-in link is on its way. It is valid for 15 minutes and can be used once." The hint routes a person who received nothing to registration or their spam folder, because the screen genuinely does not know whether a mail left (the comment at 128 to 136). "Paste the code from the link" moves to code; "Use a different address" back to email.
  5. Code phase: submit calls verify(code); ok hands the session up (onSignedIn), else the one sentence for expired, used and forged (64 to 72, 154 to 179).
  6. Signed in but workspaceState !== 'ready': WorkspaceGate takes the whole screen, re-reads the directory on mount (listWorkspaces(false), 136 to 156) and renders WorkspacePicker with onSignOut and no onDismiss, so nothing on the screen can dismiss the one question that makes the app usable (110 to 125; Shell 401 to 418).
  7. The picker ranks the list owned first then by name with de collation (rankWorkspaces), filters by matchesQuery, focuses the search box only above seven entries (93 to 95), keeps the keyboard's highlighted row inside the filtered list (99 to 102), moves with the arrow keys and commits with Enter or a click (104 to 140). A commit calls selectWorkspace; the server's session in the answer replaces the client's (App.tsx 157 to 163), and only the server's view decides whether the gate opens.
  8. The three panels: none (an account with tids_n == 0; "Check again" refetches with refresh=1; no create button, because bootstrap is a LABS_SURFACES route this host cannot reach, 144 to 153), unavailable (the server's error.message verbatim, because "the ingress does not allow it" and "the identity service has no such endpoint" are different operator tasks, 190 to 196), key_failed (the chosen workspace named, the mint error under it, "Try again" re-selects the same id, "Choose another" reveals the list, 234 to 277).
  9. ready: App mounts with key={session.subjectId} so a different subject never inherits state (57 to 62). The rail's account block shows name || email, the billed workspace with "switch" on hover, and "Gateway key " or "No gateway key" (785 to 828); "Sign out" calls signOut(), logs the report's upstream half when it failed, and location.replace('/') whatever the report says (830 to 847).
  10. Switching from the rail: listWorkspaces(false) opens the picker with onDismiss, selected marked "Current", and state: 'choose' even when ready (1054 to 1063); a successful switch keeps threads and repositories (they are the subject's) and shows a notice naming the workspace, with a warning when previousKey.ok is false (1064 to 1084).

Invariants

  • No credential in the browser: SessionUser has no token field, gateway.keyPrefix is the truncated prefix Labs returned, the cookie is HttpOnly, and the client files export no storage helper (lib/auth.ts 20 to 23; B21 Invariants).
  • The sent screen never says a mail was sent: the copy is "if there is an account", the same for a known and an unknown address, and the 202 carries nothing else (server/auth.ts 236 to 241).
  • Expired, used and forged links are one sentence on this screen and one 401 on the server (lib/auth.ts 228 to 233).
  • No mutation without an endpoint: the one write these screens make is POST /api/workspaces/select, and its effect is what the server's projection says, not what the client chose (App.tsx 157 to 163).
  • The picker never pre-selects among several: rankWorkspaces orders, active is a keyboard highlight, and a commit is a click or Enter (WorkspacePicker.tsx 13 to 24, lib/workspaces.ts 112 to 118).
  • Exactly one workspace never shows the picker (the server settles it at sign-in); zero shows its own panel, never an empty list.
  • The sign-in-path picker cannot be dismissed; the switch-path picker can (the onDismiss prop is the only difference, 49 to 54).
  • Sign-out is done when the call returns: the cookie is cleared and the record revoked before the response is written; the report is information about the upstream half, never a condition (lib/auth.ts 271 to 290).
  • The ?token= is removed from the URL before the first render completes, so it is not in history, bookmarks or the next request's Referer (300 to 313).

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.

Sign in: the address form, no password field signin server rendered, hydrated in view
Address typed: the button enables signin-filled server rendered, hydrated in view
Submitted: "One moment…" while the server asks for the mail signin-busy server rendered, hydrated in view
Check your inbox: "if there is an account", never "sent", whatever the address sent server rendered, hydrated in view
Redeem a code: the fallback when the link opens elsewhere code server rendered, hydrated in view
A ?token= that failed: expired, used or forged are one sentence link-invalid server rendered, hydrated in view
429 with Retry-After: the seconds rendered as copy rate-limited server rendered, hydrated in view
Sign-in path, nine workspaces: owned first, the divider, the search box above seven, no default picker-choose server rendered, hydrated in view
Switch path: the current one marked, Cancel and a closable backdrop picker-switch server rendered, hydrated in view
tids_n is zero: its own panel, no list, no create button picker-none server rendered, hydrated in view
Directory down: the server's reason verbatim (here its not_deployed sentence) picker-unavailable server rendered, hydrated in view
Workspace chosen, key mint failed: the choice stays, try again or choose another picker-key-failed server rendered, hydrated in view
The rail's account block: name, the billed workspace, the key prefix, never the key account server rendered, hydrated in view
What POST /api/auth/logout answered: local, gateway key, upstream session signout-report server rendered, hydrated in view
identity-auth/ui/chat-signin-and-workspace-picker/demo.tsx
/**
 * Live demo for identity-auth/ui/chat-signin-and-workspace-picker.
 *
 * The chat surface's three identity screens, from prima-chat at 743b840
 * (w5-billing-panel): SignIn.tsx (194 lines: address, "check your inbox",
 * redeem a code), WorkspacePicker.tsx (418 lines: the picker, the
 * no-workspace panel, the directory-unavailable panel, the key-failed
 * panel) and the rail's account block in App.tsx (lines 785 to 850: who is
 * signed in, which workspace is billed, which credential is spending,
 * sign out). Every network call is replaced by props: what
 * requestMagicLink and verify answered (web/src/lib/auth.ts), what
 * GET /api/workspaces answered (web/src/lib/workspaces.ts), and what
 * POST /api/auth/logout reported.
 *
 * The rebuild is in the Prima design system (D1b): the markup and the
 * class names are the chat's, the rules are carried in ./styles.ts bound
 * to the @prima-modules/prima-tokens variables where a value has a token,
 * and rendered by this component in a <style> element under `.chat-demo`.
 * The chat app does not consume the tokens package (recipe Gotchas).
 *
 * Kept from the source: the lockup on the rail classes, the accent clause
 * in the sign-in headline, the three phases and their buttons, the
 * "if there is an account" wording that never says whether a mail left,
 * the error line with its retry seconds, the picker's ranking (owned first,
 * then by name), the search box that appears above seven entries, the
 * Memberships divider, the Owner and Current badges, the four panels'
 * copy word for word. Four em dashes in the copy (SignIn.tsx 103 and
 * 142, WorkspacePicker.tsx 208, App.tsx 826) are a full stop or a comma
 * here (house rule). The sign-out report is not a screen in the app
 * (App.tsx 834 to 842 writes it to the console); the `signout-report`
 * state renders the endpoint's answer as a card beside the account block
 * so the three halves are visible. Pure helpers (rankWorkspaces,
 * matchesQuery, roleLabel) are copied from web/src/lib/workspaces.ts 123
 * to 155 so the list behaves as the app's; the interactions that stay
 * (typing, filtering, arrow keys, the phase links) touch nothing outside
 * the frame.
 *
 * Demo-only deviations, because the frame is a docs page and not the app
 * (recorded 2026-09-12): no element takes focus on mount. SignIn.tsx 110
 * and 165 put `autoFocus` on the address and code inputs and
 * WorkspacePicker.tsx 93 to 95 focus the search box above seven entries;
 * `.focus()` scrolls the focused element into view, so on a page with
 * fourteen frames the reader landed on the ninth with a search box
 * focused (measured 2026-09-12 on the built docs page: `window.scrollY`
 * 13940 at 1440 px, `activeElement` the picker's search input). Both
 * calls are left out here, which is also why every state can be
 * `hydrate: visible`. And the picker's shell is `role="group"` instead of
 * the app's `role="dialog" aria-modal="true"`: five permanent modal
 * dialogs on one page would mark the rest of the recipe inert for
 * assistive technology. The `unavailable` sample sentence is the server's
 * `not_deployed` copy (server/identity.ts 469 to 474), the one of the two
 * that names no host.
 */
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { defineStates } from '@prima-modules/demo';
import { css } from './styles';

/* ---- types (web/src/lib/auth.ts 26 to 83, 261 to 269) ------------------- */

export interface Workspace {
  id: string;
  name: string;
  slug?: string;
  role: string;
  isOwner: boolean;
}

export interface ChosenWorkspace extends Workspace {
  chosenAt: string;
}

export type WorkspaceState = 'ready' | 'choose' | 'none' | 'unavailable' | 'key_failed';

export interface SignOutReport {
  local: number;
  gatewayKey: { attempted: boolean; ok: boolean; status: number | null; note?: string };
  retiredKeys: { attempted: number; revoked: number };
  upstreamSession: { attempted: boolean; ok: boolean; status: number | null; note?: string };
}

export interface SignInProps {
  phase: 'email' | 'sent' | 'code';
  email: string;
  code: string;
  busy: boolean;
  /** A complete sentence from lib/auth.ts, or null. */
  error: string | null;
  retryAfter?: number;
}

export interface PickerProps {
  workspaces: Workspace[];
  selected: ChosenWorkspace | null;
  state: WorkspaceState;
  error: { reason: string; message: string } | null;
  keyError?: string;
  /** Present on the switch path (a Cancel button and a closable backdrop); absent on the sign-in path. */
  dismissible: boolean;
}

export interface AccountProps {
  name: string | null;
  email: string;
  workspaceName: string | null;
  gateway: { credential: 'key' | 'none'; keyPrefix?: string };
  report: SignOutReport | null;
}

export interface Props {
  screen: 'signin' | 'picker' | 'account';
  signin: SignInProps;
  picker: PickerProps;
  account: AccountProps;
}

/* ---- sample data ------------------------------------------------------ */

const idle: SignInProps = { phase: 'email', email: '', code: '', busy: false, error: null };

const owned: Workspace = { id: 'ws_owner_1', name: 'Praxis am See', slug: 'praxis-am-see', role: 'owner', isOwner: true };
const memberships: Workspace[] = [
  { id: 'ws_m_1', name: 'Zahnarzt Bahnhof', slug: 'zahnarzt-bahnhof', role: 'member', isOwner: false },
  { id: 'ws_m_2', name: 'Ärzte am Park', slug: 'aerzte-am-park', role: 'admin', isOwner: false },
  { id: 'ws_m_3', name: 'Studio Nord', slug: 'studio-nord', role: 'viewer', isOwner: false },
  { id: 'ws_m_4', name: 'Bergbahn Sample AG', slug: 'bergbahn-sample', role: 'member', isOwner: false },
  { id: 'ws_m_5', name: 'Cafe Muster', slug: 'cafe-muster', role: 'member', isOwner: false },
  { id: 'ws_m_6', name: 'Velo Werkstatt', slug: 'velo-werkstatt', role: 'desk_requester', isOwner: false },
  { id: 'ws_m_7', name: 'Hotel Beispiel', slug: 'hotel-beispiel', role: 'member', isOwner: false },
  { id: 'ws_m_8', name: 'Apotheke Mitte', slug: 'apotheke-mitte', role: 'admin', isOwner: false },
];
const many = [...memberships, owned];

const pickerBase: PickerProps = { workspaces: many, selected: null, state: 'choose', error: null, dismissible: false };

const account: AccountProps = {
  name: 'Mira Keller',
  email: 'mira@example.com',
  workspaceName: 'Praxis am See',
  gateway: { credential: 'key', keyPrefix: 'prima_pk_0ab…' },
  report: null,
};

const report: SignOutReport = {
  local: 1,
  gatewayKey: { attempted: true, ok: true, status: 204 },
  retiredKeys: { attempted: 0, revoked: 0 },
  upstreamSession: { attempted: true, ok: false, status: 404, note: 'POST /api/auth/logout is not on the ingress allowlist' },
};

const base: Props = { screen: 'signin', signin: idle, picker: pickerBase, account };

export const states = defineStates<Props>({
  signin: { label: 'Sign in: the address form, no password field', props: base, height: 640 },
  'signin-filled': { label: 'Address typed: the button enables', props: { ...base, signin: { ...idle, email: 'mira@example.com' } }, height: 640 },
  'signin-busy': { label: 'Submitted: "One moment…" while the server asks for the mail', props: { ...base, signin: { ...idle, email: 'mira@example.com', busy: true } }, height: 640 },
  sent: { label: 'Check your inbox: "if there is an account", never "sent", whatever the address', props: { ...base, signin: { ...idle, phase: 'sent', email: 'mira@example.com' } }, height: 640 },
  code: { label: 'Redeem a code: the fallback when the link opens elsewhere', props: { ...base, signin: { ...idle, phase: 'code' } }, height: 640 },
  'link-invalid': { label: 'A ?token= that failed: expired, used or forged are one sentence', props: { ...base, signin: { ...idle, phase: 'code', error: 'This link has expired, has already been used, or is invalid. Request a new one.' } }, height: 640 },
  'rate-limited': { label: '429 with Retry-After: the seconds rendered as copy', props: { ...base, signin: { ...idle, email: 'mira@example.com', error: 'Too many attempts. Wait 45 seconds.', retryAfter: 45 } }, height: 640 },
  'picker-choose': { label: 'Sign-in path, nine workspaces: owned first, the divider, the search box above seven, no default', props: { ...base, screen: 'picker' }, height: 640 },
  'picker-switch': { label: 'Switch path: the current one marked, Cancel and a closable backdrop', props: { ...base, screen: 'picker', picker: { ...pickerBase, selected: { ...owned, chosenAt: '2026-09-12T07:00:00Z' }, dismissible: true } }, height: 640 },
  'picker-none': { label: 'tids_n is zero: its own panel, no list, no create button', props: { ...base, screen: 'picker', picker: { ...pickerBase, workspaces: [], state: 'none' } }, height: 640 },
  'picker-unavailable': { label: 'Directory down: the server\'s reason verbatim (here its not_deployed sentence)', props: { ...base, screen: 'picker', picker: { ...pickerBase, workspaces: [], state: 'unavailable', error: { reason: 'not_deployed', message: 'The sign-in service offers no workspace lookup.' } } }, height: 640 },
  'picker-key-failed': { label: 'Workspace chosen, key mint failed: the choice stays, try again or choose another', props: { ...base, screen: 'picker', picker: { ...pickerBase, workspaces: [owned], selected: { ...owned, chosenAt: '2026-09-12T07:00:00Z' }, state: 'key_failed', keyError: 'The sign-in service refused to issue a key for this workspace (403).' } }, height: 640 },
  account: { label: 'The rail\'s account block: name, the billed workspace, the key prefix, never the key', props: { ...base, screen: 'account' }, height: 400 },
  'signout-report': { label: 'What POST /api/auth/logout answered: local, gateway key, upstream session', props: { ...base, screen: 'account', account: { ...account, report } }, height: 400 },
});

/* ---- helpers (web/src/lib/workspaces.ts 123 to 155) ---------------------- */

export function rankWorkspaces(workspaces: Workspace[]): Workspace[] {
  return [...workspaces].sort((a, b) => {
    if (a.isOwner !== b.isOwner) return a.isOwner ? -1 : 1;
    return a.name.localeCompare(b.name, 'de', { sensitivity: 'base', numeric: true });
  });
}

export function matchesQuery(workspace: Workspace, query: string): boolean {
  const needle = query.trim().toLowerCase();
  if (!needle) return true;
  const haystack = [workspace.name, workspace.slug, workspace.role].filter(Boolean).join(' ').toLowerCase();
  return haystack.includes(needle);
}

export function roleLabel(workspace: Workspace): string {
  switch (workspace.role.toLowerCase()) {
    case 'owner':
      return 'Owner';
    case 'admin':
      return 'Administrator';
    case 'member':
      return 'Member';
    case 'viewer':
      return 'Read access';
    default:
      return workspace.role;
  }
}

/* ---- the mark (SparkMark.tsx: the spark as one stroked path, currentColor so the two grounds recolour it) -- */

function SparkMark({ size = 20 }: { size?: number }) {
  return (
    <svg className="brand__spark" width={size} height={size} viewBox="-3 -1 54 55" aria-hidden="true" focusable="false">
      <path d="M26.0139 17.9369C26.3337 17.6167 26.7013 17.3295 27.0189 17.0029C27.9822 16.0122 28.9388 15.0024 29.8999 14.0053C30.4969 13.3869 31.7977 11.9625 32.3728 11.4662C32.5214 11.3351 32.6798 11.1505 32.913 11.0752C32.99 11.048 33.0811 11.1016 33.1379 11.1601C33.3823 11.4025 33.1995 11.6815 33.1189 11.9029C32.912 12.4405 32.7862 13.514 32.6216 14.151C32.0544 16.3238 31.4551 18.5079 30.7692 20.6511C30.7336 20.7137 30.775 20.7491 30.8375 20.7628C30.9261 20.7886 32.35 20.5498 32.5578 20.5049C33.04 20.3988 33.3899 20.2251 33.8371 20.0814C38.2847 18.6167 42.8179 17.4148 47.2048 15.7786C47.4807 15.6811 48.1723 15.208 48.3641 15.669C48.46 15.8995 47.854 16.2066 47.6734 16.349C47.0383 16.8593 46.2925 17.3016 45.5653 17.6944C43.9957 18.5421 42.4566 19.8435 40.9928 20.8682C39.4207 21.9741 37.8222 23.0463 36.2655 24.1754C35.0597 25.0517 33.9132 26.0143 32.6416 26.8067C32.5919 26.9315 33.2071 27.2429 33.3136 27.3196C34.7067 28.3207 36.0218 29.4281 37.4023 30.4551C39.5915 32.0754 42.1948 33.6421 44.5922 34.9529C45.3508 35.3637 46.2544 35.6946 46.9746 36.119C47.2479 36.2797 48.1579 36.8942 47.3559 37.0347C46.9485 37.1072 44.7606 36.4298 44.2057 36.2687C40.6538 35.2444 37.194 33.7659 33.6412 32.8206C32.9008 32.6249 32.0736 32.5099 31.365 32.2816C30.7472 32.0854 30.1916 31.7387 29.5928 31.5144C29.8157 32.3289 30.2545 33.0599 30.5778 33.8318C33.0035 39.6132 35.6172 45.3138 37.966 51.1224C38.0619 51.3529 38.2578 51.8053 38.1818 52.0394C38.0997 52.2971 37.853 52.3343 37.6865 52.1287C34.1881 47.6411 31.0813 42.8937 27.8423 38.2215C26.8535 36.7916 25.7751 35.4148 24.8032 33.9718C24.7195 33.9799 24.7095 34.0335 24.6804 34.0938C24.1347 35.1227 23.8199 36.3559 23.3933 37.4357C22.795 38.9691 22.1883 40.3769 21.6638 41.9558C21.1822 43.3908 20.8075 45.0668 20.1851 46.4301C20.1081 46.6003 20.0055 46.9224 19.7752 46.7608C19.6992 46.7091 19.6722 46.6328 19.6494 46.548C19.6013 46.3506 19.6107 45.6827 19.6068 45.4268C19.5261 42.1968 19.5289 38.9372 19.3773 35.7107C19.3289 34.6345 19.1718 33.6181 19.0246 32.5482L16.1533 33.613C13.5271 34.8199 10.9009 36.0267 8.29677 37.2758C6.77917 38.005 4.89907 39.2198 3.2814 39.5842C2.84643 39.6808 2.41532 39.4616 2.81823 39.0905C3.5201 38.4423 4.44171 37.8236 5.19331 37.1935C7.67746 35.115 10.1658 33.0279 12.6368 30.9326C13.7704 29.9746 14.8775 28.9616 16.0374 28.0372C16.0252 27.9414 15.9781 27.9509 15.9046 27.9269C15.5309 27.8088 14.4916 27.7474 14.0502 27.7033C12.5109 27.5542 10.9526 27.4332 9.41351 27.3054C5.95239 27.0279 2.48388 27.1176 -0.980774 26.8914C-1.13571 26.8818 -1.31886 26.7107 -1.22455 26.5488C-1.13857 26.404 -0.355236 26.1701 -0.16245 26.1234C1.16813 25.8246 2.64689 25.6593 3.99094 25.3987C7.18939 24.79 10.3696 24.1091 13.5494 23.4069C14.6057 23.1693 15.646 22.866 16.7167 22.7305C15.3818 20.7298 14.102 18.681 12.41 16.9565C11.79 16.3253 10.9643 15.7452 10.4043 15.0999C10.1964 14.859 9.90422 14.5835 10.2875 14.3408C10.3706 14.29 10.8085 14.5283 11.0073 14.601C11.3403 14.7263 11.7948 14.9301 12.1159 15.1239L19.7524 18.5777C19.6206 17.1022 19.5209 15.6153 19.3677 14.1402C19.0525 11.186 18.6366 8.25302 18.39 5.2888C18.2752 3.94303 18.2246 2.57458 18.0906 1.23562C18.048 0.829073 18.4364 0.784684 18.702 1.00525C19.059 1.30069 19.4843 2.70822 19.7155 3.21962C21.5588 7.2917 23.4342 11.3524 25.0639 15.5143C25.3747 16.312 25.6492 17.1297 26.0155 17.9006L26.0139 17.9369Z" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
    </svg>
  );
}

function Brand() {
  return (
    <div className="signin__brand">
      <span className="rail__mark" aria-hidden="true">
        <SparkMark size={30} />
      </span>
      <span className="rail__name">
        Prima <span className="brand__li">Chat</span>
      </span>
      <span className="rail__swiss">CH</span>
    </div>
  );
}

/* ---- SignIn.tsx ---------------------------------------------------------- */

function SignIn({ phase: phaseInitial, email: emailInitial, code: codeInitial, busy, error, retryAfter }: SignInProps) {
  const [phase, setPhase] = useState(phaseInitial);
  const [email, setEmail] = useState(emailInitial);
  const [code, setCode] = useState(codeInitial);
  const stop = (e: React.FormEvent) => e.preventDefault();

  return (
    <div className="signin">
      <div className="signin__card">
        <Brand />

        {phase === 'email' ? (
          <form onSubmit={stop}>
            <h1>
              Sign in <span className="accent">to Prima Chat.</span>
            </h1>
            <p className="signin__lede">We'll send you a sign-in link. No password. There isn't one.</p>
            <label htmlFor="signin-email">Email address</label>
            <input id="signin-email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@yourcompany.ch" />
            <button type="submit" className="btn btn--primary" disabled={busy || !email.trim()}>
              {busy ? 'One moment…' : 'Send sign-in link'}
            </button>
            <button type="button" className="signin__link" onClick={() => setPhase('code')}>
              I already have a code
            </button>
          </form>
        ) : null}

        {phase === 'sent' ? (
          <div>
            <h1>Check your inbox</h1>
            <p className="signin__lede">
              If there is an account for <strong>{email}</strong>, a sign-in link is on its way. It is valid for 15 minutes and can be used once.
            </p>
            <p className="signin__hint">
              Nothing arrived? Then this address may have no account yet, or the email is sitting in your spam folder.
            </p>
            <button type="button" className="btn btn--primary" onClick={() => setPhase('code')}>
              Paste the code from the link
            </button>
            <button type="button" className="signin__link" onClick={() => setPhase('email')}>
              Use a different address
            </button>
          </div>
        ) : null}

        {phase === 'code' ? (
          <form onSubmit={stop}>
            <h1>Redeem a code</h1>
            <p className="signin__lede">
              Opening the link directly is best. If it lands in another window, paste the value after <code>token=</code> here.
            </p>
            <label htmlFor="signin-code">Sign-in code</label>
            <input id="signin-code" type="text" required spellCheck={false} value={code} onChange={(e) => setCode(e.target.value)} placeholder="e.g. 8f2a…" />
            <button type="submit" className="btn btn--primary" disabled={busy || !code.trim()}>
              {busy ? 'Checking…' : 'Sign in'}
            </button>
            <button type="button" className="signin__link" onClick={() => setPhase('email')}>
              Request a new link
            </button>
          </form>
        ) : null}

        {error ? (
          <p className="signin__error" role="alert">
            {error}
            {retryAfter ? ` (${retryAfter}s)` : ''}
          </p>
        ) : null}

        <p className="signin__foot">Your sign-in details never leave this server. The browser only gets a session id.</p>
      </div>
    </div>
  );
}

/* ---- WorkspacePicker.tsx --------------------------------------------------- */

function PickerShell({ children, dismissible, onKeyDown }: { children: React.ReactNode; dismissible: boolean; onKeyDown?: (e: React.KeyboardEvent) => void }) {
  // The app's shell is role="dialog" aria-modal="true" (WorkspacePicker.tsx Shell, 392 to 418); a group here, since a docs page shows five of these at once (header).
  return (
    <div className="wsp" role="group" aria-label="Workspace" onKeyDown={onKeyDown}>
      {/* The backdrop closes only on the switch path; on the sign-in path a stray click must not dismiss the one screen that can make the app usable. */}
      <div className="wsp__backdrop" data-dismisses={dismissible ? 'true' : undefined} />
      <div className="wsp__card">{children}</div>
    </div>
  );
}

function WorkspacePicker({ workspaces, selected, state, error, keyError, dismissible }: PickerProps) {
  const [query, setQuery] = useState('');
  const [active, setActive] = useState<string | null>(selected?.id ?? null);
  const [showList, setShowList] = useState(false);
  const searchRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  const ranked = useMemo(() => rankWorkspaces(workspaces), [workspaces]);
  const shown = useMemo(() => ranked.filter((w) => matchesQuery(w, query)), [ranked, query]);

  /* The app focuses the search box on mount above seven entries (WorkspacePicker.tsx 93 to 95); not here, see the header: a docs page must not scroll to the frame. */

  useEffect(() => {
    if (active && shown.some((w) => w.id === active)) return;
    setActive(shown[0]?.id ?? null);
  }, [shown, active]);

  function onKeyDown(event: React.KeyboardEvent): void {
    if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
    event.preventDefault();
    const index = shown.findIndex((w) => w.id === active);
    const next = event.key === 'ArrowDown' ? index + 1 : index - 1;
    const target = shown[Math.max(0, Math.min(shown.length - 1, next))];
    if (target) {
      setActive(target.id);
      listRef.current?.querySelector(`[data-workspace="${target.id}"]`)?.scrollIntoView({ block: 'nearest' });
    }
  }

  if (state === 'none') {
    return (
      <PickerShell dismissible={dismissible}>
        <h1>No workspace</h1>
        <p className="wsp__lede">
          Your account belongs to no workspace. Since all usage is billed to a workspace, the chat cannot be used without one.
        </p>
        <p className="wsp__hint">
          A workspace is created for your organisation, or granted to you by its owner. Once that has happened, a click on “Check again” here is enough.
        </p>
        <div className="wsp__actions">
          <button type="button" className="btn">Check again</button>
          <button type="button" className="btn btn--ghost">Sign out</button>
        </div>
      </PickerShell>
    );
  }

  if (state === 'unavailable') {
    return (
      <PickerShell dismissible={dismissible}>
        <h1>Workspaces unavailable</h1>
        <p className="wsp__lede">{error?.message || 'The list of your workspaces could not be loaded. That is a fault on our side.'}</p>
        <p className="wsp__hint">
          Without this list there is no telling which workspace your usage would be billed to. The chat stays locked until there is. Billing guessed at cannot be corrected later.
        </p>
        <div className="wsp__actions">
          <button type="button" className="btn">Try again</button>
          <button type="button" className="btn btn--ghost">Sign out</button>
        </div>
      </PickerShell>
    );
  }

  if (state === 'key_failed' && !showList) {
    return (
      <PickerShell dismissible={dismissible}>
        <h1>No gateway key</h1>
        <p className="wsp__lede">
          No gateway key could be issued for <strong>{selected?.name || 'this workspace'}</strong>.
        </p>
        {keyError ? <p className="wsp__error">{keyError}</p> : null}
        <p className="wsp__hint">Your choice of workspace is unaffected and stays as it is. You can try again or choose a different workspace.</p>
        <div className="wsp__actions">
          <button type="button" className="btn" disabled={!selected}>Try again</button>
          <button
            type="button"
            className="btn btn--ghost"
            onClick={() => {
              setQuery('');
              setActive(null);
              setShowList(true);
            }}
          >
            Choose another
          </button>
        </div>
      </PickerShell>
    );
  }

  const searchable = ranked.length > 7;

  return (
    <PickerShell dismissible={dismissible} onKeyDown={onKeyDown}>
      <h1>{selected ? 'Switch workspace' : 'Choose a workspace'}</h1>
      <p className="wsp__lede">Your account belongs to {ranked.length} workspaces. Choose the one usage should be billed to.</p>
      <p className="wsp__hint">
        Billing applies from the switch onward and cannot be changed retroactively. Usage already billed stays with the previous workspace.
      </p>

      {searchable ? (
        <input ref={searchRef} className="wsp__search" type="search" value={query} spellCheck={false} placeholder="Search…" aria-label="Search workspaces" onChange={(e) => setQuery(e.target.value)} />
      ) : null}

      <ul className="wsp__list" ref={listRef} role="listbox" aria-label="Workspaces">
        {shown.map((workspace, index) => {
          const isCurrent = selected?.id === workspace.id;
          const startsMemberships = !workspace.isOwner && index > 0 && shown[index - 1].isOwner;
          return (
            <React.Fragment key={workspace.id}>
              {startsMemberships ? (
                <li className="wsp__divider" aria-hidden="true">
                  <span>Memberships</span>
                </li>
              ) : null}
              <li>
                <button
                  type="button"
                  data-workspace={workspace.id}
                  role="option"
                  aria-selected={active === workspace.id}
                  className={`wsp__row${active === workspace.id ? ' wsp__row--active' : ''}${isCurrent ? ' wsp__row--current' : ''}`}
                  onMouseEnter={() => setActive(workspace.id)}
                >
                  <span className="wsp__row-name">{workspace.name}</span>
                  <span className="wsp__row-meta">
                    {workspace.isOwner ? <span className="wsp__badge wsp__badge--owner">Owner</span> : <span className="wsp__badge">{roleLabel(workspace)}</span>}
                    {isCurrent ? <span className="wsp__badge wsp__badge--current">Current</span> : null}
                  </span>
                </button>
              </li>
            </React.Fragment>
          );
        })}
        {shown.length === 0 ? <li className="wsp__none">No workspace matches “{query}”.</li> : null}
      </ul>

      <div className="wsp__actions">
        <button type="button" className="btn btn--ghost">Refresh list</button>
        {dismissible ? (
          <button type="button" className="btn btn--ghost">Cancel</button>
        ) : (
          <button type="button" className="btn btn--ghost">Sign out</button>
        )}
      </div>
    </PickerShell>
  );
}

/* ---- the rail's account block (App.tsx 785 to 850) and the sign-out report --- */

function Account({ name, email, workspaceName, gateway, report: r }: AccountProps) {
  return (
    <div className="account-demo">
      <nav className="rail-band" aria-label="Rail (account block)">
        <div className="rail-band__head">
          <span className="rail__mark" aria-hidden="true">
            <SparkMark size={22} />
          </span>
          <span className="rail__name">
            Prima <span className="brand__li">Chat</span>
          </span>
          <span className="rail__swiss">CH</span>
        </div>
        <div className="rail-band__filler" />
        <div className="rail__account">
          <div className="rail__account-who" title={email}>
            <span className="rail__account-name">{name || email}</span>
            <button type="button" className="rail__account-ws" title={workspaceName ? `Usage is billed to “${workspaceName}”. Click to switch` : 'Choose a workspace'}>
              <span className="rail__account-ws-name">{workspaceName || 'No workspace'}</span>
              <span className="rail__account-ws-switch">switch</span>
            </button>
            <span className="rail__account-key">{gateway.credential === 'key' ? `Gateway key ${gateway.keyPrefix ?? ''}` : 'No gateway key'}</span>
          </div>
          <button type="button" className="rail__account-out">
            Sign out
          </button>
        </div>
      </nav>

      {r ? (
        <div className="report">
          <h2>Sign-out report</h2>
          <p>What POST /api/auth/logout answered. The app treats sign-out as done when this returns and reloads; the report is written to the console (App.tsx 834 to 842).</p>
          <dl>
            <dt>local</dt>
            <dd>
              <span className="ok">{r.local} session revoked, cookie cleared</span>
            </dd>
            <dt>gatewayKey</dt>
            <dd>
              {r.gatewayKey.attempted ? (
                <span className={r.gatewayKey.ok ? 'ok' : 'no'}>
                  {r.gatewayKey.ok ? 'revoked upstream' : 'not revoked'} ({r.gatewayKey.status ?? 'no response'})
                </span>
              ) : (
                'not attempted'
              )}
            </dd>
            <dt>retiredKeys</dt>
            <dd>
              {r.retiredKeys.revoked} of {r.retiredKeys.attempted} parked keys revoked
            </dd>
            <dt>upstreamSession</dt>
            <dd>
              <span className={r.upstreamSession.ok ? 'ok' : 'no'}>
                {r.upstreamSession.ok ? 'ended' : 'not ended'} ({r.upstreamSession.status ?? 'no response'})
              </span>
              {r.upstreamSession.note ? `, ${r.upstreamSession.note}` : ''}
            </dd>
          </dl>
        </div>
      ) : null}
    </div>
  );
}

/* ---- root --------------------------------------------------------------- */

export default function Demo({ screen, signin, picker, account: acct }: Props) {
  return (
    <div className="chat-demo prima-root">
      <style>{css}</style>
      {screen === 'signin' ? <SignIn {...signin} /> : null}
      {screen === 'picker' ? (
        <div className="signin">
          <WorkspacePicker {...picker} />
        </div>
      ) : null}
      {screen === 'account' ? <Account {...acct} /> : null}
    </div>
  );
}
Component tree, from the recipe
Root (App.tsx 63 to 128)
  [session undefined]   div.signin > div.signin__card > p.signin__lede "One moment…"        (takeTokenFromUrl, verify, currentSession)
  [no session]          SignIn (initialError when ?token= failed)
                          div.signin > div.signin__card
                            div.signin__brand   span.rail__mark > SparkMark 30 | span.rail__name "Prima Chat" | span.rail__swiss "CH"
                            [email]  form: h1 "Sign in <span.accent>to Prima Chat.</span>", p.signin__lede, label + input#signin-email, button.btn--primary "Send sign-in link" | "One moment…", button.signin__link "I already have a code"
                            [sent]   h1 "Check your inbox", p.signin__lede "If there is an account for <email> ...", p.signin__hint, button "Paste the code from the link", button.signin__link "Use a different address"
                            [code]   form: h1 "Redeem a code", p.signin__lede, label + input#signin-code, button "Sign in" | "Checking…", button.signin__link "Request a new link"
                            [error]  p.signin__error[role=alert] "<sentence> (<retryAfter>s)"
                            p.signin__foot
  [workspaceState != ready]  WorkspaceGate > WorkspacePicker (onSignOut, no onDismiss)
                               div.wsp[role=dialog aria-modal] > div.wsp__backdrop + div.wsp__card
                                 [none]         h1 "No workspace", lede, hint; btn "Check again", btn--ghost "Sign out"
                                 [unavailable]  h1 "Workspaces unavailable", lede = error.message verbatim, hint; btn "Try again", btn--ghost "Sign out"
                                 [key_failed]   h1 "No gateway key", lede with selected.name, p.wsp__error keyError, hint; btn "Try again", btn--ghost "Choose another"
                                 [choose]       h1 "Choose a workspace" | "Switch workspace", lede (count), hint (billing from now on)
                                                [> 7] input.wsp__search; ul.wsp__list[role=listbox] > li > button.wsp__row[role=option] (name, badge Owner | role, badge Current); li.wsp__divider "Memberships"
                                                div.wsp__actions: btn--ghost "Refresh list", btn--ghost "Cancel" (switch path) | "Sign out" (sign-in path)
  [ready]  App > nav.rail > div.rail__account (App.tsx 785 to 850)
             div.rail__account-who: span.rail__account-name, button.rail__account-ws (name + "switch"), span.rail__account-key "Gateway key <prefix>" | "No gateway key"
             button.rail__account-out "Sign out"   -> signOut() -> report to console -> location.replace('/')
           [switching]  WorkspacePicker with onDismiss (Cancel, closable backdrop), selected marked Current
Design system
prima
Packages
@prima-modules/prima-tokens 0.1.0, from prima.li 03f4205, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild chat-signin-and-workspace-picker/demo.tsx, built in the Prima design system: the markup and class names are the chat's, the rules are carried in styles.ts and rendered by the component in a <style> element scoped under .chat-demo, and every local custom property the rules use is bound to the @prima-modules/prima-tokens variable of the same value where one exists (--c-violet, --c-violet-600, --c-ink, --c-cream, --radius 20px, --radius-lg 28px, --space-sm to --space-2xl, --font-body, --font-display for the italic accent) and to the chat stylesheet's literal where none does (the secondary text #625c6c, the hairline at 0.12 where the tokens' --border-default is 0.14, the control border at 0.5, the status reds and greens, the scrim, the 6px, 10px and 14px radii, the mono stack; styles.css 77 to 215). The docs host loads prima-tokens.css for the prima system (RENDERING.md 3.3), so the frame renders in the site's values with the chat's rules on top. Three layout changes: .signin is min-height: 640px instead of 100vh, .wsp is position: absolute inside the demo root instead of fixed, and .wsp__list carries max-height: 300px where the chat has none (styles.css 2207 to 2217 lets the list flex inside a card bounded at min(86vh, 720px); vh resolves against the docs viewport, not the 640 px stage, so the list needs its own bound; recorded 2026-09-12). Four em dashes in the copy (SignIn.tsx 103 and 142, WorkspacePicker.tsx 208, App.tsx 826) are a full stop or a comma in the demo (house rule). The spark is the real path from SparkMark.tsx with stroke="currentColor".

Three things the demo changes because the frame is a docs page and not the app (all 2026-09-12): nothing takes focus on mount. SignIn.tsx 110 and 165 put autoFocus on the address and the code input, and WorkspacePicker.tsx 93 to 95 focus the search box above seven entries; .focus() scrolls the focused element into view, and on the built docs page the reader landed on the ninth frame with the search box focused (measured on the npm run build:index output of prima.li at 86fe9d1: window.scrollY 13940 at 1440 px and 21365 at 400 px, document.activeElement the picker's input.wsp__search, no hash in the URL). The demo leaves both calls out, so every state is hydrate: visible. Second, the picker's shell is role="group" with the same aria-label instead of the app's role="dialog" aria-modal="true" (Shell, 392 to 418): five permanent modal dialogs on one page would mark the rest of the recipe inert for assistive technology. Third, the unavailable state's sentence is the server's not_deployed copy, "The sign-in service offers no workspace lookup." (server/identity.ts 469 to 474), because the other sentence the server writes for this panel names the API host.

States (rendering.states, in order): signin (the address form, no password field), signin-filled (an address typed, the button enabled), signin-busy ("One moment…", disabled), sent ("Check your inbox" with the "if there is an account" lede for a fictional address), code ("Redeem a code" with the token= hint), link-invalid (the code phase with the one sentence for a dead link, the initialError path), rate-limited (the address form with "Too many attempts. Wait 45 seconds. (45s)"), picker-choose (the sign-in path: nine fictional workspaces, one owned, the divider, the search box, no row current, "Sign out" and no "Cancel"), picker-switch (the switch path: the owned one marked "Current", "Cancel"), picker-none, picker-unavailable (the server's not_deployed sentence verbatim, above), picker-key-failed (the chosen name, the mint error, "Try again" and "Choose another"), account (the rail's account block on the ink band: name, the billed workspace with "switch", "Gateway key prima_pk_0ab…", "Sign out"), signout-report (the same block beside a card that lists what POST /api/auth/logout answered: local revoked, the gateway key revoked upstream, the upstream session not ended with 404; this card is the demo's, the app writes the report to the console). No state declares a viewport: the card is 440 px wide and the picker 520 px, and both flow.

Tokens and classes: --cream ground and a white card with --line border, --r-xl corners and --shadow-lg; the headline in the display face at weight 500 with tight tracking and one italic accent clause in the serif (.accent, styles.css 327 to 331: "one clause, one heading"); --violet on the primary button, the mark, the links, the Owner badge and the current row's inset bar; --violet-50 on the highlighted row; --swiss (the residency red) on the "CH" badge and the picker's error line; --no, --no-bg, --no-line on the sign-in error; --ok on the Current badge; the ink band's --on-ink and --on-ink-60 in the account block. The .btn base has no background; .btn--primary and .btn--ghost are the two used here, at the dense app size (14.5px), while the sign-in card's own full-width button is the landing size (16.5px, 1988 to 2009).

Responsive: the card is max-width: 440px on a centred grid with --s-5 padding, so it fills a phone's width; the picker card is min(520px, 100%) with a bounded height and the list scrolling inside it (2154 to 2166); the rail is a drawer below the app's breakpoint (not this recipe). Empty state: the none panel. Error states: the sign-in error line, the picker's wsp__error, the unavailable and key_failed panels. Loading states: the "One moment…" card before the first currentSession (Root, App.tsx 84 to 93), and the four busy labels: "One moment…" on the address form (rendered by signin-busy), "Checking…" on the code form and the none panel's "Check again" (WorkspacePicker.tsx 176), "Issuing…" on the key_failed panel's "Try again" (260), "Loading…" on the unavailable panel's "Try again" and the picker's "Refresh list" (220, 376).

What the demo deliberately does not render: the network (every button that would call the server is inert; typing, the phase links, the search filter and the arrow keys work), the mail, the ?token= landing (a docs page has no magic link), the Root card before the first currentSession and the picker's three busy labels ("Checking…", "Issuing…", "Loading…" are states of a request in flight; signin-busy shows the one on the sign-in form), the conversation UI around the rail (the account block is shown on a 264 px ink band with a filler), the switch notice, and the everywhere variant of sign-out.

Security & tenancy

What the browser may hold: the HttpOnly session cookie (unreadable by script), the projection SessionUser in React state (no token, the key prefix only, the chosen workspace as a read-only copy), and the typed address and code in component state until submit. Never a key, a Labs token, a refresh token or a settable tenant id (B21 Invariants; sessions.json on the server holds those, mode 0600).

Origin and CSRF: every call is same-origin with credentials: 'same-origin'; the cookie is SameSite=Lax, so a cross-site POST carries it nowhere, and the server's sameOriginOnly refuses a non-GET whose Origin host is not the request Host with 403 bad_origin (server/auth.ts 210 to 221; mounted first, server/index.ts 53). Lax rather than Strict because a magic link is a top-level navigation from a mail client (B21). No CORS: the app and its API are one origin.

Surface gating: the chat server sends surface: prima_chat on the magic-link request and the verify (B5; server/identity.ts), and the resulting Labs token is admitted on ALL_SURFACES routers only; an account without the prima_chat grant gets the server's surface_denied sentence on the sign-in screen (403 from POST /api/auth/magic-link or /verify). What is hidden versus enforced: nothing on these screens is hidden by CSS; the picker is not rendered when there is one workspace because the server settled it; the "Cancel" button exists only on the switch path because the prop is absent on the sign-in path; the spending routes are enforced by requireWorkspace regardless of what the client shows.

Attacker-controlled inputs the screens send: email (syntax-checked twice, client and server, then forwarded), token (forwarded, never stored, scrubbed from the URL), workspaceId (UUID-shaped or 403, then membership on two sides). What the screens render from the server: error sentences the chat server wrote (its own, or B21's mapped copies of upstream refusals; never an upstream body), workspace names from the person's own directory (React-escaped), and the key prefix.

Tests

  • Colocated with the demo: chat-signin-and-workspace-picker/demo.test.tsx (vitest, jsdom, @testing-library/react): the three helpers copied from lib/workspaces.ts (owned first then de collation, the field-insensitive match, the role labels with pass-through); no input[type=password] in any sign-in phase; the sent lede names the address and never says a mail was sent; the 429 line reads "Too many attempts. Wait 45 seconds. (45s)" and the dead-link line the one sentence; the nine-entry picker shows the search box, an Owner first, one Memberships divider, no current row and no Cancel; the switch path marks one row Current and offers Cancel; the two searchable states leave document.activeElement on the body after mount and their shell carries no aria-modal (the docs-scroll defect of 2026-09-12, Gotchas); none, unavailable and key_failed render no listbox and unavailable shows the reason verbatim; the account block shows the prefix and never a full key, and the report card names the gateway revoke and the 404. Passed 11 of 11 on 2026-09-12 with npx vitest run --config <a config whose include is modules/identity-auth/ui/*/demo.test.tsx>; the root vitest.config.mjs include covers packages/*/test and test/ only (Gotchas). The file registers afterEach(cleanup) because the root runs with globals: false.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs identity-auth/ui/chat-signin-and-workspace-picker renders the fourteen states with renderToString (all are hydrate: visible since 2026-09-12, when the demo dropped the focus-on-mount calls), with fetch stubbed.
  • In the chat repo at 743b840: scripts/auth-check.mjs (npm run auth-check, 100 passed on 2026-09-12, B21 Tests) is the hermetic gate for the routes these screens call, including "the select response carries no usable credential", the 202 with nothing else, the 403 for another subject's workspace and the sign-out report; web/src/lib/billing.test.ts covers the billing client's readFailure, not these two files; scripts/lang-check.mjs walks the copy. There is no unit test of SignIn.tsx or WorkspacePicker.tsx and no Playwright spec (scripts/render.mjs is the client's render check, F32).
  • What a new app adds: a jsdom test of SignIn with fetch mocked that asserts the sent phase renders the same for a 202 whatever the address, that a 429 with Retry-After: 90 renders "2 minutes", and that a ?token= is gone from location.search after takeTokenFromUrl; a test of WorkspacePicker that Enter commits the highlighted row and Escape calls onDismiss only when it is given; and a Playwright walk against the auth-check stand-in that signs in, is asked once, switches, and signs out with the report shown.

Deploy & ops

Build target: the Vite bundle of prima-chat/web, served by the chat's Express server from dist/ (server/index.ts 193 to 199: express.static with index: false and index.html for every other path); in development Vite runs in middleware mode (185 to 191). Env baked at build: none for these screens (there is no PUBLIC_* or VITE_* read in web/src/lib/auth.ts or workspaces.ts; the surface, the auth base and the cookie name are the server's, server/env.ts 80 to 83 and 103). CSP and CORS: none of the app's own; same origin throughout. Deploy path: deploy/ship.sh rsyncs the tree to the estate VM and builds and starts the image there (B21 Deploy & ops); the ship gate refuses an .env that carries a shared key. Rollback is the image tag's. Nothing to migrate.

Operational prerequisites these screens depend on: the ingress allowlist for the API host must carry the six upstream paths B21 names, and /api/auth/logout is not on it, which is why the account block's sign-out report shows 404 for the upstream session (B21 Gotchas). A person's account must hold the prima_chat grant, or the sign-in screen shows the surface_denied sentence at the first step.

Gotchas

  • 2026-09-12 (measured at 743b840, web/src/styles.css 77 to 215): the chat declares its own :root palette, copied by hand from prima.li's global.css (the comment at 78), and reads no token package; @prima-modules/prima-tokens (pinned to prima.li at 03f4205) has the same violet, ink and cream, but the hairline differs (--line 0.12 alpha against --border-default 0.14), the type scale and radii are named differently (--r-sm 10px has no token; --radius-sm is 8px), and the secondary-text ink #625c6c, the control border, the status colours and the scrim exist only in the chat file; the body face is the same first face with a different fallback stack (styles.css 205 has Karla and "Segoe UI" between "ES Rebond Grotesque" and the system faces, packages/prima-tokens/src/tokens.css 72 has neither), so the demo's --font binding to --font-body is not value-identical, only invisible on the docs host. The demo's styles.ts binds what matches and carries the rest as literals with the line cited. Fix: the chat imports @prima-modules/prima-tokens and keeps a short bridge for its app-only values, so the two stop drifting.
  • 2026-09-12 (measured at 743b840): the sign-out report is a console line, not a screen (App.tsx 834 to 842 logs gatewayKey.ok and upstreamSession.status when the upstream half failed, then reloads). The inventory's tree names "sign-out report local | gatewayKey | upstreamSession" as rendered; the demo renders the endpoint's answer as a card so the three halves are visible, and marks it as the demo's. A rebuild that wants the person to see it shows the card once on the sign-in screen after the reload.
  • 2026-09-12 (measured at 743b840): listWorkspaces turns every non-2xx into unavailable with the client's own network reason (lib/workspaces.ts 63 to 73), so a 401 from an expired session on the picker shows "Your workspaces could not be loaded. Try again." with a "Try again" button that keeps failing, while currentSession() would have shown the sign-in screen. Fix: a 401 from GET /api/workspaces reloads to the sign-in screen (the same rule the rail's sign-out uses).
  • 2026-09-12 (measured at 743b840): the unavailable panel shows error.message verbatim and the server writes it from the upstream's classification (ingress, not_deployed, denied, network, unknown, B21); those sentences are operator-facing by design and name the API edge. On a customer's build the sentence must not name an internal host; the reason token is what to key the copy on.
  • 2026-09-12 (measured at 743b840): SignIn.tsx carries the sign-in error in p.signin__error with role="alert" but the address input never gets aria-invalid (the stylesheet has the rule at 1983 to 1986, the component never sets the attribute), and verify in the code phase reads a token the person pasted from a link whose value is single-use, so a second submit of the same code is the same "expired, used or invalid" sentence. Fix: set aria-invalid on the input the error belongs to; disable the code form after a successful verify (the component unmounts anyway).
  • 2026-09-12 (measured at 743b840): WorkspacePicker focuses the search box on mount only above seven entries (93 to 95) and moves the highlight with the arrow keys, but the highlighted row has aria-selected while the listbox has no aria-activedescendant, and the search input is not linked to the list. Rendering trap (measured 2026-09-12 on the built docs page): .focus() scrolls the focused element into view, so a frame that focuses on mount scrolls the reader to itself, and hydrate: only makes it worse, not better (a client-only state mounts at page load, wherever the reader is; a visible one mounts once it is already on screen). The demo's first version had the two searchable states hydrate: only with the focus call kept, and the page opened at the ninth frame with the search box focused inside an aria-modal region. The demo now leaves the focus calls out (Frontend); a rebuild that keeps them in a docs frame uses focus({ preventScroll: true }).
  • 2026-09-12 (measured at 743b840): the picker's hint says billing "cannot be changed retroactively" and the key_failed panel says "Your choice of workspace is unaffected and stays as it is"; both are true of the server (chooseWorkspace records the choice even when the mint fails, B21 step 4), which is why "Try again" re-selects the same id rather than reopening the list. A rebuild keeps that order (mint first, swap, retire) or the copy stops being true.
  • 2026-09-12 (measured at 743b840): four em dashes in the shipped copy (SignIn.tsx 103, the sentence after "No password", and 142; WorkspacePicker.tsx 208, in the unavailable hint; App.tsx 826); the demo's copy replaces them (house rule). The … ellipsis character in the busy labels and the placeholder is kept, since it is the copy.
  • 2026-09-12 (measured at 743b840 and fd8558f): the sign-out report's upstreamSession is {ok: false, status: 404} on the live estate because POST /api/auth/logout is not on the API host's ingress allowlist (server/auth.ts 286 to 312; B21 Gotchas), so the Labs access token in sessions.json outlives the chat session for its remaining life even though the browser's cookie is dead. The account block does not show this; only the console line does. Fix is at the edge (allowlist the path), not on these screens.
  • 2026-09-12 (kit): the root vitest.config.mjs include is packages/*/test/** and test/**, so the colocated demo.test.tsx does not run under npm test and needs a config that names it (Tests). Fix: add modules/**/ui/*/*.test.tsx to the root include.

Reference implementation

Branch: prima-chat w5-billing-panel at 743b840 (2026-09-12). Paths relative to the repo root:

  • web/src/components/SignIn.tsx (194 lines): the header's two decisions (5 to 20: no password field; why a code box exists), state (36 to 41), handle (43 to 52), the two submits (54 to 72), the lockup (85 to 93), the three phases (95 to 179), the error line (181 to 186), the foot (188 to 190).
  • web/src/components/WorkspacePicker.tsx (418 lines): the three rules (13 to 24), the twenty-one-entry design case (26 to 31), props (38 to 56), state (69 to 83), ranking and filtering (85 to 89), the focus rule (93 to 95), the highlight rule (99 to 102), commit (104 to 114), the keys (116 to 140), the none panel (144 to 186), unavailable (190 to 230), key_failed (234 to 277), the picker (281 to 390), Shell (392 to 418).
  • web/src/components/SparkMark.tsx: why the mark is inline (1 to 18), the path (23 to 40).
  • web/src/lib/auth.ts (313 lines): the difference from prima.li (4 to 23), the types (26 to 83), AuthErrorCode and AuthResult (85 to 105), post (123 to 141), requestMagicLink (177 to 203), verify (212 to 244), currentSession (247 to 259), SignOutReport and signOut (261 to 290), takeTokenFromUrl (300 to 313).
  • web/src/lib/workspaces.ts (155 lines): the rule (1 to 15), listWorkspaces (59 to 80), selectWorkspace (83 to 110), rankWorkspaces (123 to 128), matchesQuery (131 to 139), roleLabel (142 to 155).
  • web/src/App.tsx: Root (63 to 128), WorkspaceGate (136 to 181), the account block (785 to 850), the switcher (1054 to 1085).
  • web/src/styles.css: the palette (77 to 215), the base rules (237 to 262), .accent (327 to 331), the brand classes (453 to 524), .btn (801 to 891), .signin* (1908 to 2043), .rail__account* (2046 to 2135), .wsp* (2141 to 2296).
  • server/auth.ts (347 lines): credentialFor (91 to 96), DENIALS and requireUser (100 to 134), WORKSPACE_DENIALS and requireWorkspace (154 to 192), sameOriginOnly (210 to 221), the routes (225 to 347); server/routes/workspaces.ts (107 lines); server/sessions.ts: COOKIE_NAME (70), publicSession (877 to 921), the cookie writer (845 to 859).
  • scripts/auth-check.mjs: the hermetic gate.
  • The demo: modules/identity-auth/ui/chat-signin-and-workspace-picker/demo.tsx, styles.ts, demo.test.tsx.
  • Upstream, alchemy_labs at fd8558f: backend/api/routers/auth_router.py request_magic_link and verify_magic_link (B5), list_my_workspaces (733 to 823, B2); backend/api/routers/developer_router.py (the ai key mint, B11).

Deviations of the reference from this recipe, all dated 2026-09-12: the hand-copied palette instead of the token package; the report logged, not shown; the 401-as-unavailable on the picker; operator-facing reasons on a user screen; no aria-invalid and no aria-activedescendant; four em dashes; the upstream logout 404 at the edge. Deviations of the demo from the reference, same date: no focus on mount (autoFocus on the two sign-in inputs, the picker's search-box focus), role="group" for the picker shell, the third layout change on .wsp__list, and the not_deployed sentence as the unavailable sample (Frontend).

Acceptance checks

From prima-chat at 743b840, or from a new app built to this recipe:

  1. grep -c '<input' web/src/components/SignIn.tsx prints 3 (the address at 106, the code at 162, and the header comment at line 8 that quotes the forbidden <input type="password">; grep -c 'id="signin-' web/src/components/SignIn.tsx prints 2 for the rendered inputs alone) and grep -n 'type="password"' web/src/components/SignIn.tsx prints only line 8, that comment; grep -c "localStorage\|sessionStorage" web/src/lib/workspaces.ts web/src/components/SignIn.tsx web/src/components/WorkspacePicker.tsx prints 0 for each, and 1 for web/src/lib/auth.ts (the header comment at 14 naming prima.li's interim).
  2. grep -n "credentials: 'same-origin'" web/src/lib/auth.ts web/src/lib/workspaces.ts | wc -l prints 3; grep -c "replaceState" web/src/lib/auth.ts prints 2 (the comment and the call).
  3. npm run auth-check prints 100 passed, 0 failed (the routes these screens call, including the key-free select response and the 202 with nothing else).
  4. Browser walk-through against the auth-check stand-in or a staging upstream: (a) open /, observe the card with the spark, "Prima Chat", "CH" and "Sign in to Prima Chat." with the accent clause in italic serif, no password field; (b) submit an address that has no account, observe "Check your inbox" with "If there is an account for
    " and the spam hint, and no difference from an address that has one; (c) submit six times inside a minute against a rate-limited upstream, observe "Too many attempts. Wait seconds. (s)" and the seconds counting from Retry-After; (d) click "I already have a code", paste a used code, observe "This link has expired, has already been used, or is invalid. Request a new one."; (e) open a valid link, observe the address bar cleaned of ?token= before the app renders, then as a one-workspace subject the conversation UI at once with "Gateway key prima_pk_…" in the rail; (f) as a subject with nine workspaces, observe the picker with the search box focused, the owned workspace first, the "Memberships" divider, no row highlighted as current and no "Cancel"; press ArrowDown twice and Enter, observe the app open and the rail naming that workspace; (g) hover the workspace name in the rail, click "switch", observe the picker with the current row marked "Current" and "Cancel"; press Escape, observe it close; (h) as a subject with no workspace, observe "No workspace" with "Check again" and "Sign out" and no list; (i) with the upstream's workspace list unreachable, observe "Workspaces unavailable" with the server's reason; (j) click "Sign out", observe the reload to the sign-in card and, in the console, the line naming the gateway key revoked and the sign-in service unreachable (404) on the estate; GET /api/auth/session afterwards answers {session: null, reason: 'no_cookie'}.
  5. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs identity-auth/ui/chat-signin-and-workspace-picker prints ok for the fourteen states and 0 failures; the colocated test prints 11 passed.
  6. Verifier: there is no verify block, because the chat's screens need a signed-in upstream the harness does not boot (labs-local.sh boots Labs, not the chat server); the comparison is by eye against the auth-check stand-in run locally (npm run dev in prima-chat with the stand-in from scripts/auth-check.mjs), state signin against / signed out, picker-choose against a nine-workspace subject; the report is written by hand into the review, not committed.

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.