---
slug: chat-ui-design-philosophy
tags: [design-philosophy, chat-ui, ai-ux, ui, mobile, streaming, agents]
description: A complete build spec for an AI chat interface that works on phones and desktops, with exact layout numbers, keyboard and scroll behaviour, streaming states, accessibility, performance and an acceptance checklist an agent can verify against.
ground: "#FAFAF9"
ink: "#1C1B19"
accent: "#2F5BD3"
font: IBM Plex Sans
mono: JetBrains Mono
layout: words-first
---
# Chat UI: the complete spec for mobile and desktop

This is a build spec for an AI chat interface: give it to an agent and it should produce a chat that works properly on a phone and on a desktop on the first try. Every rule here is concrete enough to check. The common failures (a composer hidden behind the iPhone keyboard, Enter sending half a Japanese word, the page yanking you down while you read, a long code line widening the whole screen) each have a rule and a test below.

The philosophy behind it is short. A chat for an AI isn't a messaging app: it's a live window onto a process that reads, thinks, calls tools and streams an answer. Keep the answer primary, show the work quietly, and never take control away from the person, including control of their own scroll position.

## When this applies

- **Fits:** anywhere a person talks to a model or agent: assistants, copilots, support bots, agent consoles with tool use, on the web or in a web view.
- **Partly fits:** a one-shot generator ("write me a caption") needs the composer, streaming, stop and output rules, not the history sidebar. A helper panel inside another product needs the thread and composer rules but should use the host app's navigation.
- **Doesn't fit:** people messaging each other (they need presence and read receipts, not tool cards). And don't force chat onto a task a form does better: if users always give the same three inputs, give them three fields.

## How to use this spec (for agents)

1. Read the whole spec before writing code. Section numbers are the build order.
2. Build the shell first (section 1) and check it at the five sizes in the acceptance checklist before adding features. Most chat bugs are shell bugs.
3. Model messages as typed parts (section 0), not strings.
4. Use the brand guide's In use card as a working reference. It implements the shell, the composer's Enter, IME and auto-grow rules, send and stop, streaming states, the anchored scroll with Jump to latest, and the history drawer. It does that in about 200 lines of plain HTML, CSS and JS.
5. Finish by going through every item in the Acceptance checklist, using the test setup in section 14. Don't report done until each one passes, and say which ones you couldn't test (for example a real iPhone).

## 0. Messages are parts, not strings

One assistant turn can hold reasoning, tool calls with inputs and outputs, text, code, tables, images and sources. Give each part a type and a component: `text`, `reasoning`, `tool` (name, input, status, output or error), `source`, `file`. Libraries such as the AI SDK, AI Elements and prompt-kit stream messages in this shape. Each message also has `id`, `role` (`user` or `assistant`), `status` (`sending`, `streaming`, `done`, `stopped`, `error`) and `createdAt`. Every later rule hangs off these fields.

## 1. The shell

**Viewport meta.** `width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content`. Never `maximum-scale=1` or `user-scalable=no`: people need to zoom.

**One scroller.** The page itself never scrolls. The shell is a grid that fills the screen, and only the thread scrolls:

```css
html, body { height: 100%; margin: 0; overflow: hidden; }
.shell { height: 100vh; height: 100dvh; height: var(--app-h, 100dvh);
         display: grid; grid-template-columns: auto minmax(0, 1fr); }
.main  { display: grid; grid-template-rows: auto minmax(0, 1fr) auto;
         grid-template-columns: minmax(0, 1fr); min-width: 0; }
.thread { overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
```

`minmax(0, 1fr)` on every track is not optional. A plain `1fr` sizes to its widest content, so one long code line or URL makes the whole layout wider than the phone. The same goes for any flex child that truncates text: give it `min-width: 0`.

**Breakpoints.** One breakpoint at 768px, measured on the app's width.

| | Phone (under 768px) | Desktop (768px and up) |
|---|---|---|
| History | A drawer from the left, `min(85%, 320px)` wide, over a scrim | A 264px column, collapsible to nothing with the menu button or Ctrl/Cmd+B |
| Header | 52px: menu, title (truncated with an ellipsis), new chat | Same, 52px |
| Reading column | Full width, 16px side padding | Centred, max 760px (about 70 characters), 20px padding |
| User message | Right-aligned bubble, max 85% of the column | Max 70% of the column, 560px at most |
| Assistant message | Unboxed, full column width | Same |
| Touch targets | 44 by 44px minimum | 32px is fine for pointer-only controls |
| Message actions | Always visible under each answer | Shown on hover and focus, always on the latest answer |

**Safe areas.** With `viewport-fit=cover`, pad the header with `env(safe-area-inset-top)`, the composer with `calc(12px + env(safe-area-inset-bottom))`, and the drawer with both, so nothing sits under the notch or the home indicator.

## 2. The phone keyboard

This is the part most chat UIs get wrong.

- **Android (Chrome, Firefox):** `interactive-widget=resizes-content` makes the keyboard shrink the layout, so `100dvh` already fits above it.
- **iOS Safari** doesn't support `interactive-widget` (through Safari 26.5): the keyboard overlays the page, so a bottom-pinned composer ends up behind it. Size the shell to the visual viewport instead:

```js
const vv = window.visualViewport
if (vv) {
  const fit = () => {
    document.documentElement.style.setProperty('--app-h', vv.height + 'px')
    window.scrollTo(0, 0)            // iOS scrolls the page to show the input; undo it
  }
  vv.addEventListener('resize', fit)
  vv.addEventListener('scroll', fit)
  fit()
}
```

- **No zoom on focus:** the composer's font size is at least 16px on every device. iOS zooms the page when a smaller input gets focus.
- **Keyboard hints:** `enterkeyhint="send"`, `autocapitalize="sentences"`, `spellcheck="true"`, and no `autocomplete` dropdown.
- **Keep the keyboard up after sending.** Call `preventDefault()` on the send button's `pointerdown`, so tapping it doesn't blur the textarea.
- **Don't autofocus the composer on phones.** Opening a chat shouldn't throw the keyboard over the conversation. On desktop, do autofocus it on load and after New chat.

## 3. The composer

- **Auto-grow** from one line to about eight (200px), then scroll inside. Use `field-sizing: content` where it's supported and a script fallback elsewhere (set `height: auto`, then `height = min(scrollHeight, 200)px`), since Safari support isn't settled.
- **Enter:**
  - On a device with a fine pointer and a hardware keyboard, Enter sends and Shift+Enter adds a new line.
  - On touch devices (`(pointer: coarse)`), Enter adds a new line and the send button sends. Phone keyboards have no comfortable Shift+Enter.
  - **IME:** if `event.isComposing || event.keyCode === 229`, do nothing. An Enter that confirms a Japanese, Chinese or Korean word must never send. Safari reports `isComposing` as false on that Enter, which is why the `229` check is needed.
- **Send and stop are one button.**
  - It sits in the same place, 38px on desktop and 44px on phones.
  - It's disabled (`aria-disabled="true"`, visibly dimmed) while the input is empty or only whitespace.
  - While an answer streams it becomes Stop, with `aria-label="Stop generating"`. Esc also stops on desktop.
- **Drafts survive.** Save the draft per conversation (for example in localStorage, keyed by chat id), restore it when the chat reopens, and clear it on send. A failed send puts the text back.
- **Attachments:**
  - Adding a file: a + button, paste (images from the clipboard), and drag and drop on desktop with a full-panel drop target. On phones, `<input type="file" accept="image/*,…">` without forcing `capture`.
  - Each file shows as a chip above the text with its name, progress and a remove button.
  - Send stays disabled until uploads finish.
  - Files that are too large or the wrong type get a plain message naming the limit.
- **Hint line** on desktop only: "Enter to send, Shift+Enter for a new line". Hide it on phones, where it isn't true.
- **Placeholder** says who you're talking to ("Message the assistant"), not a slogan.

## 4. The thread

- **Text:** 16px body with 1.6 line height on every screen. Code is 13 to 14px mono.
- **Spacing:** 28px between turns and 8 to 12px between parts in a turn.
- **Markdown:**
  - Headings render smaller than page headings: the largest is 1.25em.
  - Tables and code blocks scroll sideways inside their own box (`overflow-x: auto`). They never widen the page.
  - Code keeps `white-space: pre` and gets a header with the language and a Copy button.
  - Long words and URLs break (`overflow-wrap: anywhere`).
  - Images fit the column and open full size on tap.
- **Direction:** put `dir="auto"` on every message body, so Arabic or Hebrew answers lay out right to left inside an English UI.
- **Actions:**
  - Under each answer: Copy, Regenerate, and Good/Bad if you collect feedback. On user messages: Edit and Copy.
  - Copy changes its label to "Copied" for 1.5 seconds.
  - Desktop shows them on hover and focus, and always on the latest answer. Phones show them always, at 44px.
- **Edit and regenerate branch.**
  - Editing a user message replaces the bubble with an inline editor (Cancel and Send). On send, everything after that message is replaced.
  - Keep the old version reachable with a "‹ 2 / 3 ›" switcher on the message.
- **Timestamps** stay out of the way: show them in a tooltip on desktop, or as a date divider between days.
- **Empty state:**
  - A short question and three or four starter prompts that fit the product.
  - Two columns on desktop, one on phones, each at least 44px tall.
  - They disappear after the first message.
  - Never a logo wall or a marketing paragraph.

## 5. Streaming and states

A message moves through these states. Each one gets its own visible treatment:

| State | What the person sees |
|---|---|
| `sending` | Their message appears in the thread immediately. Never wait for the server to echo it. |
| `waiting` | Within 300ms of sending, a quiet "Thinking" indicator where the answer will start. After 10 seconds without output, it adds "Still working". |
| `streaming` | Text lands in place with a caret at the end. The send button is Stop. |
| `done` | Caret gone, actions appear, code highlighting runs. |
| `stopped` | The partial answer stays, with "Stopped. The answer above is incomplete." and Regenerate. |
| `error` | The partial answer stays, with a plain sentence about what happened and a Retry button. Never a raw stack trace or status code alone. |

- **Batch tokens per animation frame.** Collect incoming chunks and apply them once per `requestAnimationFrame`. Never re-render the whole thread per token. Only the streaming message changes.
- **Partial markdown:** use a renderer that copes with unterminated blocks (Streamdown does), so a half-open code fence already renders as code instead of flashing raw backticks.
- **Highlight code after the block closes,** not on every chunk. Show plain mono while it streams.
- **Tool calls and reasoning** render as compact cards, collapsed by default: what was called, its input, and a status that shows in both words and colour (Running, Done, Failed). Expand on click. The answer is never pushed below a wall of reasoning.
- **Offline:** show a banner and disable Send with the reason. If the connection drops mid-stream, keep what arrived and offer "Connection lost. Retry".
- **Rate limits:** say when they can try again ("Try again in 40 seconds"). Don't just say "error".
- **Sources:** cite inline with numbered markers that link to source cards. Only cite what the model actually used.

## 6. Scroll

- **On send, anchor the question.** Scroll so the new user message sits at the top of the thread, and let the answer stream into the space below it. Give the latest turn a `min-height` of the thread's height so there's room to scroll it up. The reader starts at the top of the answer and reads down while it streams.
- **Never chase the stream.** Once the answer runs past the bottom edge, the page stays still. If the person scrolls up to reread something, nothing pulls them back down.
- **Jump to latest:**
  - A floating pill, centred just above the composer. It appears whenever the bottom is more than 48px away.
  - Clicking it scrolls smoothly to the end, or instantly under reduced motion.
- **Opening a conversation** lands at the latest message. Returning to one restores the previous scroll position.
- **Loading older messages** at the top keeps what's on screen in place (`overflow-anchor`, or adjust `scrollTop` by the added height).
- **Long threads:** put `content-visibility: auto` with `contain-intrinsic-size: auto 400px` on each turn. Virtualise past a few hundred messages, and keep a search of your own when you do (section 13).
  - Exempt the latest turn (`content-visibility: visible`). It carries the anchoring `min-height`, and a skipped latest turn throws off the scroll position you just set.

## 7. History sidebar

- New chat at the top, with Ctrl/Cmd+Shift+O on desktop, and search right under it.
- Group by recency: Today, Yesterday, Last 7 days, Last 30 days, Older.
- Give every chat a real title, generated from the first message and renameable. A column of "New chat" entries is useless.
- Mark the active chat with `aria-current="page"`. Every chat has its own URL, so it can be bookmarked, opened in a new tab and shared.
- **Per-item menu** with Rename, Pin, Share and Delete. Delete asks first, then confirms with a toast that has Undo.
- Sharing says who can see it: "Only you" or "Anyone with the link".
- Page history in (20 at a time) with skeleton rows, and never block the open conversation on it.
- **Phone drawer:**
  - Opens from the menu button and closes on the scrim, on Esc, or on picking a chat.
  - Focus moves into it on open and back to the menu button on close.
  - `aria-expanded` on the menu button tracks it.
  - Don't add an edge-swipe gesture: it fights the iOS back gesture.
- Temporary chats that stay out of history, and memory shown separately from history with its own controls.

## 8. Accessibility

- **Landmarks:**
  - The sidebar is `<aside>`/`<nav>` with labels, and the thread has `role="log"` and a label.
  - Each message is an `<article>` whose label says who wrote it ("You", "Assistant").
- **Screen readers:**
  - Don't put `aria-live` on the streaming text: it reads every fragment.
  - Keep one visually hidden `aria-live="polite"` region and announce "Answer ready", "Stopped" or the error when a message settles.
- **Keyboard:**
  - Everything works without a mouse, with a visible focus ring (2px, the accent colour) everywhere.
  - Tab order: header, thread, composer. Esc closes menus and the drawer, then stops a stream.
  - `/` focuses the composer when focus isn't in a text field.
  - Shortcuts are listed in a `?` sheet and don't take over browser shortcuts.
- **Contrast:** 4.5:1 for text, 3:1 for icons and control borders, in light and dark themes.
- **Zoom and text size:** the layout holds at 200% zoom and with the OS text size at its largest.
- **Reduced motion:** under `prefers-reduced-motion`, no blinking caret, no shimmer, and instant scrolling.
- **Labels:** icon-only buttons get an `aria-label`. Send, Stop and the menu button change their labels with their state.

## 9. Performance budget

- Typing in the composer never waits on the thread. Keep composer state separate from the message list, so a keystroke doesn't re-render messages.
- Stream rendering stays under one frame: batch per `requestAnimationFrame`, and memoise finished messages.
- First paint doesn't wait on history, models list or user settings. Show the shell and composer at once, with skeletons where data is coming.
- Load the code highlighter and the markdown extras lazily, after first paint.
- Images in messages use `loading="lazy"` with known dimensions, so they don't shift text.

## 10. Streaming in real conditions

Section 5 covers one answer streaming into one tab that stays open. Real use breaks all three assumptions.

- **Hidden tabs.** Browsers pause `requestAnimationFrame` in background tabs, so a frame-batched stream piles up unseen and lands in one jump when the tab returns. While `document.hidden` is true, flush on a timer instead (about 250ms). On `visibilitychange`, flush whatever is waiting at once: a frame queued just before the tab hid would otherwise wait for it to show again.

```js
let pending = null
const flush = () => { pending = null; render(buffer) }
const schedule = () => {               // call on every chunk
  if (pending) return
  pending = document.hidden
    ? { timer: setTimeout(flush, 250) }
    : { raf: requestAnimationFrame(flush) }
}
const cancel = () => {
  if (pending?.raf) cancelAnimationFrame(pending.raf)
  if (pending?.timer) clearTimeout(pending.timer)
  pending = null
}
const onVisibility = () => { if (pending) { cancel(); flush() } }
document.addEventListener('visibilitychange', onVisibility)
// When the stream settles: cancel(), render the final text, remove the listener.
```

- **Resumable streams.** A reload, a dropped network or a phone sleeping mid-answer must not lose the answer.
  - Generation runs on the server, independent of the connection. Each answer has a stream id and the client tracks the offset it has received.
  - On reconnect or reopen, the client asks for the rest from that offset (SSE `Last-Event-ID`, or a `GET /streams/:id?from=n`) and the message keeps its `streaming` state with its caret.
  - A chat reopened while its answer is still generating shows that answer streaming, not an empty turn and not a duplicate request.
- **Sending while an answer streams.** Pick one behaviour and show it:
  - **Queue** (the default for agents doing work): the new message shows as "Queued" under the running answer and sends when it finishes. It can be edited or removed while queued.
  - **Interrupt:** sending stops the current answer (it becomes `stopped`, kept as is) and starts the new one.
  - Never send two requests that both write into the same turn.
- **Many tabs, many devices.** The same chat open twice stays in sync: new messages and stream progress go to every open copy (a `BroadcastChannel` across tabs, the server's event stream across devices). Only one copy owns a given stream, and the others follow it by stream id. Drafts stay per device.
- **Retries are idempotent.** Every send carries a client-generated message id, so a retry after a timeout can't create a second copy of the question or start a second answer.

## 11. Agents: approvals, long work and live output

When the assistant acts (runs tools, edits files, spends money, sends email), the thread becomes a control surface.

- **Approvals.** A tool call that needs permission renders as a card that stops the run:
  - It shows what will happen in plain words ("Send an email to ana@example.com"), the exact input, and what it touches.
  - Actions: Approve, Deny, and Edit (change the input, then approve). Optional "Always allow this tool in this chat", scoped and revocable in settings.
  - The run waits visibly ("Waiting for your approval") and the composer stays usable. A pending approval survives a reload, and it expires with a clear note rather than approving by default.
  - Destructive actions repeat the key fact on the button ("Delete 14 files"), never just "OK".
- **Long work.** Tasks that take minutes run on the server and outlive the tab.
  - Show progress as steps with words ("Reading 3 of 12 files"), not a bare spinner, plus elapsed time and a Stop that really cancels the server job.
  - When the person leaves, keep running. When it finishes, notify them (a tab title badge, a browser notification if they opted in, an unread dot on the chat in history).
- **Parallel work.** Several tool calls or sub-agents running at once collapse into one group card ("Running 4 tasks", with each line's status in words and colour). Expanding one shows its own input, output and nested steps. The answer text is never interleaved with their output.
- **Interactive output.** The model can return UI, not only text: a form to fill, choices to pick, a result card with actions, a preview.
  - Render it from a typed part (section 0) with a known component, never from model-written HTML.
  - Its actions send structured events back to the run, and the card shows what was chosen after it's used, so the thread reads correctly later.
  - Large generated things (documents, code files, images, apps) open in a side panel on desktop and a full-screen sheet on phones, with versions, Copy and Download. The thread keeps a compact card that reopens it.

## 12. Trust and safety

- **Treat model output as untrusted input.**
  - Render markdown with HTML disabled or sanitised (an allowlist sanitiser such as DOMPurify after rendering), never `innerHTML` of raw model text.
  - Links: only `http`, `https` and `mailto`. Open them with `rel="noopener noreferrer"`. Show the real domain on hover or long-press, and mark links whose text differs from their target.
  - Images the model references load through your proxy or not at all. A remote image URL can carry data out of the conversation in its query string.
  - Set a Content Security Policy that blocks inline scripts, so a rendering mistake can't become script execution.
- **Refusals and moderation look different from errors.** A refusal is a normal answer in the assistant's voice, with no Retry button that pretends something broke. Output blocked by a filter says so plainly ("This answer was blocked by a content filter") with a way to rephrase, never as "Something went wrong".
- **Sessions expire mid-chat.** When auth runs out, keep the draft, the thread and any partial answer, and sign in again in place (a modal or a popup). Then retry the request that failed. Never bounce the person to a login page that loses their work.
- **Prompt-injected content** from tools (web pages, files, email) is shown as quoted source material, visually distinct from the assistant's own words, so a page that says "ignore your instructions" can't look like the assistant talking.

## 13. Limits, models and rich content

- **Context limits.** Show when a conversation nears the model's context window ("This chat is getting long. Older messages may be summarised."). When older turns are summarised or dropped, put a divider in the thread saying so, with the summary viewable. Offer "Start a new chat with a summary".
- **Models and usage.**
  - A model picker sits in the composer or header and shows the current model. Each answer records which model wrote it (in its actions menu or a tooltip), because regenerating with another model is common.
  - Where usage costs the person money or quota, show remaining quota before they hit the wall, and say what happens at the limit. A rate limit says when they can try again (section 5).
- **Search, export and print.**
  - Search across chats lives in the history sidebar. Find within the open chat uses the browser's own Ctrl/Cmd+F, which means long threads mustn't virtualise away text that isn't on screen without a search of their own.
  - Export a chat as Markdown, and print with a stylesheet that drops the shell (`@media print`: no header, composer or sidebar, full-width messages, code wrapped).
- **Maths and diagrams.** Render LaTeX (KaTeX) and diagrams (Mermaid) once their block closes. While they stream, show the source in mono, the same rule as code highlighting. A diagram that fails to parse falls back to its source with a small note, never a blank box.
- **Voice.**
  - Dictation fills the composer for review and doesn't send by itself. Show a live level meter and a clear stop.
  - Read-aloud plays one answer at a time with pause and speed controls, and highlights the sentence being read where possible.
  - Voice mode states (listening, thinking, speaking, interrupted) map onto the same message states as text.
- **Interface language.** Every UI string comes from a message catalogue. With `dir="rtl"` on the document, the whole shell mirrors: sidebar on the right, user bubbles on the left, send button on the left. Use logical CSS properties (`margin-inline-start`, `inset-inline-end`) so it mirrors without a second stylesheet. Dates and numbers use `Intl`.

## 14. Testing and shipping

- **Mock the stream, then test the hard cases.** Build a fake backend (a route interceptor or a tiny server) that streams scripted replies with controlled timing. Each acceptance item runs against it in a headless browser:
  - slow tokens, a 15-second stall, a mid-stream disconnect, malformed chunks, a 429 with `Retry-After`, a 401 mid-stream;
  - a 300-character URL, a 200-column code line, an unclosed code fence, a table and right-to-left text;
  - a hidden tab during streaming, and a reload during streaming.
- **Mark what needs a real device.** The iOS keyboard, IME composition in Safari and screen reader output can't be proven headless. List them as manual checks, and say in the report which ones weren't run.
- **Sub-path deploys.** When the app is served under a path (`/chat/`, a preview path, a reverse proxy prefix), three things must agree: the bundler's base for assets, the router's basename, and the API base URL. Derive all three from one setting. A mismatch renders a blank page with no error, so load the deployed URL after every deploy instead of trusting the build.

## 15. Theme and tokens

Respect `prefers-color-scheme`, with a manual override. These are a representative set: what matters is quiet ground, ink for the answer, one accent kept for links, citations and focus, and status colours only for status.

```css
:root {
  --ground: #FAFAF9; --surface: #FFFFFF; --sunken: #F2F1EE; --line: #E4E2DD;
  --ink: #1C1B19; --muted: #6B6862; --accent: #2F5BD3;
  --run: #8F5E12; --ok: #24733E; --err: #C2412D;
}
@media (prefers-color-scheme: dark) {
  :root {
    --ground: #151413; --surface: #1D1C1A; --sunken: #262421; --line: #34322E;
    --ink: #ECEAE6; --muted: #A19D96; --accent: #8DB4DC;
    --run: #E0A54A; --ok: #6BBF83; --err: #F08A76;
  }
}
```

## Acceptance checklist

Test at 375×667 (small phone), 390×844 (phone), 768×1024 (tablet), 1280×800 (laptop) and 1920×1080. Then test with keyboard only, with a screen reader (VoiceOver or NVDA), with reduced motion and in dark mode. Every item must pass.

**Shell**

1. The page never scrolls as a whole, and there's no horizontal scrollbar at any size, even with a 300-character URL and a 200-column code line in an answer.
2. Header, thread and composer fit the screen at every size, and the thread is the only scroller.
3. Nothing sits under the notch or home indicator on an iPhone.
4. Pinch zoom still works.

**Keyboard on phones**

5. On iPhone Safari and Android Chrome, focusing the composer keeps it visible right above the keyboard, and the latest message stays visible above it.
6. Focusing the composer doesn't zoom the page.
7. Sending keeps the keyboard open.
8. Opening a chat on a phone doesn't open the keyboard.

**Composer**

9. It grows to about eight lines, then scrolls inside.
10. Desktop: Enter sends and Shift+Enter adds a line. Phone: Enter adds a line and the button sends.
11. With a Japanese IME, pressing Enter to confirm a word doesn't send the message (test in Safari as well as Chrome).
12. Send is disabled for empty or whitespace input. While streaming it's a Stop button, and Esc stops on desktop.
13. A draft survives switching chats and reloading.
14. A failed send restores the text and offers Retry.
15. Pasting an image attaches it, and dragging a file onto the window on desktop shows a drop target.

**Thread**

16. The user's message appears the instant they send.
17. A thinking indicator appears within 300ms, and text streams in without flicker or layout jumps.
18. Half-finished markdown (an open code fence, a half table) never shows raw syntax.
19. Code and tables scroll sideways in their own box, and Copy on code works.
20. Copy, Regenerate and Edit work. Edit branches, and the old version can still be reached.
21. Stop leaves the partial answer with a clear "stopped" note. A dropped connection keeps the partial answer and offers Retry.
22. Right-to-left text in an answer reads right to left.

**Scroll**

23. After sending, the question sits at the top and the answer fills in below it.
24. Scrolling up during a stream is never interrupted.
25. "Jump to latest" appears whenever the bottom is out of view, and works.
26. Loading older messages doesn't move what's on screen.
27. A 500-message thread scrolls smoothly.

**History**

28. On a phone the drawer opens from the menu, traps focus, and closes on the scrim, on Esc and on picking a chat.
29. On desktop the sidebar collapses with the menu button and Ctrl/Cmd+B.
30. Every chat has its own URL. The active one is marked, titles are real, and Delete asks, then offers Undo.

**Accessibility**

31. Everything works by keyboard, with a visible focus ring.
32. The screen reader announces "Answer ready" once per answer, not every token.
33. Icon buttons have names, and Send and Stop are announced correctly.
34. Text contrast is at least 4.5:1 in both themes, with no colour-only status.
35. The layout holds at 200% zoom, and reduced motion removes the caret blink, the shimmer and smooth scrolling.

**Performance**

36. Typing stays instant while an answer streams.
37. The shell and composer appear before history or settings load.

**Real conditions**

38. An answer streaming in a background tab is up to date when you switch back to it, with no jump of buffered text. (Test it headless by overriding `document.hidden` to true and `requestAnimationFrame` to never fire, then streaming slowly: the text must still grow.)
39. Reloading mid-answer shows the same answer still streaming, and the question isn't sent twice.
40. Sending while an answer streams queues or interrupts, as designed, and never mixes two answers into one turn.
41. The same chat open in two tabs shows the same messages and the same stream.

**Agents**

42. An action that needs approval stops and shows exactly what it will do. Approve, Deny and Edit work, and a pending approval survives a reload.
43. Long work shows steps in words, keeps running when the tab closes, and Stop cancels it on the server.
44. Interactive cards record what was chosen and still read correctly after reload.

**Trust**

45. Model output containing `<script>`, an `onerror` image or a `javascript:` link renders as harmless text.
46. A refusal reads as an answer, a filtered answer says it was filtered, and neither looks like a crash.
47. When the session expires mid-chat, signing in again keeps the draft, the thread and the partial answer.

**Limits and content**

48. Summarised or dropped context is marked in the thread.
49. LaTeX and Mermaid show their source while streaming and render once closed, and a broken diagram falls back to its source.
50. With the interface in an RTL language, the whole shell mirrors.
51. The app works when deployed under a sub-path: assets, routes and API calls all resolve.

## Applied well vs. misapplied

| Idea | Applied well | Misapplied |
|---|---|---|
| Shell | One grid, `minmax(0, 1fr)` tracks, only the thread scrolls | `position: fixed` composer over a scrolling body; one long URL widens the page |
| Phone keyboard | `interactive-widget` plus a `visualViewport` fallback for iOS | The composer disappears behind the iPhone keyboard |
| Enter | Sends on desktop, new line on phones, ignored during IME composition | Half-typed Japanese words get sent |
| Scroll | The question anchors at the top; the page never chases the stream | The thread drags the reader down while they read |
| States | Thinking, streaming, done, stopped and error each look different | A spinner, then the whole answer at once |
| Actions | Always visible at 44px on phones, hover-revealed on desktop | Hover-only actions that a phone can never reach |
| Screen reader | One polite "Answer ready" | `aria-live` on the stream, reading every fragment aloud |
| History | A drawer on phones, titled chats with URLs | A permanent sidebar squeezing a phone screen |

## Anti-patterns

- Disabling zoom to stop iOS zooming the input, instead of using a 16px font.
- `height: 100vh` on phones, which is taller than the visible screen once the browser bars show.
- A plain `1fr` grid track around content with long lines.
- Auto-scrolling to the bottom on every token.
- Sending on Enter during IME composition.
- Hover-only message actions.
- Hiding the answer under expanded reasoning.
- A raw "Error 500" with no way forward.
- Clearing the composer before the message is safely sent.
- Re-rendering the whole thread on every streamed token.
- Batching tokens with `requestAnimationFrame` alone, so hidden tabs freeze the stream.
- Tying generation to the HTTP connection, so a reload or a sleeping phone loses the answer.
- Rendering model-written HTML or loading model-chosen image URLs directly.
- Showing a refusal or a content-filter block as a generic error with Retry.
- Approval dialogs whose button just says "OK".
- A login redirect that throws away the draft and the thread.

## Brand guide

```html example Palette | half
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--ground:#FAFAF9;--surface:#FFFFFF;--sunken:#F2F1EE;--ink:#1C1B19;--muted:#6B6862;--line:#E4E2DD;--accent:#2F5BD3;--run:#8F5E12;--ok:#24733E;--err:#C2412D}
*{box-sizing:border-box}
body{margin:0;padding:28px;background:var(--ground);color:var(--ink);font:14px/1.5 'IBM Plex Sans',system-ui,sans-serif}
.lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
h2{font-size:20px;font-weight:600;margin:0 0 4px;letter-spacing:-.01em}
.note{color:var(--muted);margin:0 0 22px;font-size:13px}
.groups{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:20px}
.grp .lbl{margin-bottom:10px}
.sw{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface);margin-bottom:10px}
.sw .c{height:52px;border-bottom:1px solid var(--line)}
.sw .t{padding:8px 10px}
.sw b{font-weight:600;font-size:13px;display:block}
.sw code{font:12px "JetBrains Mono",ui-monospace,monospace;color:var(--muted)}
.sw p{margin:2px 0 0;font-size:12px;color:var(--muted)}
.rule{margin-top:18px;padding-top:14px;border-top:1px solid var(--line);font-size:13px}
</style></head><body>
<p class="lbl">01 / Palette</p>
<h2>Quiet ground, one accent, honest status</h2>
<p class="note">The guide sets no colors, so this is a representative palette built by its rules: the answer is primary, the process stays quiet.</p>
<div class="groups">
 <div class="grp"><p class="lbl">Ground</p>
  <div class="sw"><div class="c" style="background:#FAFAF9"></div><div class="t"><b>Canvas</b><code>#FAFAF9</code><p>Thread background</p></div></div>
  <div class="sw"><div class="c" style="background:#F2F1EE"></div><div class="t"><b>Sunken</b><code>#F2F1EE</code><p>Sidebar, tool cards, user turns</p></div></div>
 </div>
 <div class="grp"><p class="lbl">Ink</p>
  <div class="sw"><div class="c" style="background:#1C1B19"></div><div class="t"><b>Ink</b><code>#1C1B19</code><p>The answer text</p></div></div>
  <div class="sw"><div class="c" style="background:#6B6862"></div><div class="t"><b>Muted</b><code>#6B6862</code><p>Reasoning, labels, timestamps</p></div></div>
 </div>
 <div class="grp"><p class="lbl">Accent</p>
  <div class="sw"><div class="c" style="background:#2F5BD3"></div><div class="t"><b>Cite blue</b><code>#2F5BD3</code><p>Links, citations and focus only</p></div></div>
  <div class="sw"><div class="c" style="background:#E4E2DD"></div><div class="t"><b>Line</b><code>#E4E2DD</code><p>Borders and dividers</p></div></div>
 </div>
 <div class="grp"><p class="lbl">Status only</p>
  <div class="sw"><div class="c" style="background:#8F5E12"></div><div class="t"><b>Running</b><code>#8F5E12</code><p>Tool call in progress</p></div></div>
  <div class="sw"><div class="c" style="background:#24733E;height:24px"></div><div class="c" style="background:#C2412D;height:24px"></div><div class="t"><b>Done / Error</b><code>#24733E / #C2412D</code><p>Tool result state</p></div></div>
 </div>
</div>
<p class="rule"><b>Rule.</b> Amber, green and red mean a tool state. Never decoration, never on the answer itself.</p>
</body></html>
```

```html example Type | half
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--ground:#FAFAF9;--surface:#FFFFFF;--ink:#1C1B19;--muted:#6B6862;--line:#E4E2DD;--accent:#2F5BD3}
*{box-sizing:border-box}
body{margin:0;padding:28px;background:var(--ground);color:var(--ink);font:14px/1.5 'IBM Plex Sans',system-ui,sans-serif}
.lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
h2{font-size:20px;font-weight:600;margin:0 0 18px;letter-spacing:-.01em}
.spec{display:grid;grid-template-columns:1.3fr 1fr;gap:20px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px}
.big{font-size:44px;line-height:1.1;letter-spacing:-.02em;font-weight:500}
.big small{display:block;font-size:14px;color:var(--muted);letter-spacing:0;margin-top:8px;font-weight:400}
.mono .big{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:34px;letter-spacing:0}
dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:13px;align-content:center}
dt{color:var(--muted)}dd{margin:0}
.scale{margin-top:20px;border-top:1px solid var(--line)}
.row{display:grid;grid-template-columns:200px 1fr;gap:16px;padding:10px 0;border-bottom:1px solid var(--line);align-items:baseline}
.row span{font-size:12px;color:var(--muted)}
.row code{font:12px "JetBrains Mono",monospace}
@media(max-width:560px){.spec,.row{grid-template-columns:1fr}.big{font-size:34px}}
</style></head><body>
<p class="lbl">02 / Type</p>
<h2>One reading face, one machine face</h2>
<div class="spec">
 <div class="big">IBM Plex Sans<small>Answers, titles, controls. Built for long reading at chat widths.</small></div>
 <dl><dt>Use</dt><dd>Display and text</dd><dt>Weights</dt><dd>400 / 500 / 600</dd><dt>Tracking</dt><dd>-0.02em display, 0 body</dd><dt>Fallback</dt><dd>system-ui, sans-serif</dd></dl>
</div>
<div class="spec mono">
 <div class="big">search_docs()<small style="font-family:'IBM Plex Sans',sans-serif">Tool names, inputs, outputs, code, filenames.</small></div>
 <dl><dt>Use</dt><dd>Mono: tool calls and code</dd><dt>Weights</dt><dd>400 / 500</dd><dt>Tracking</dt><dd>0</dd><dt>Fallback</dt><dd>ui-monospace, monospace</dd></dl>
</div>
<div class="scale">
 <div class="row"><span>Display <code>24 / 600</code></span><div style="font-size:24px;font-weight:600;letter-spacing:-.02em">What can I help with?</div></div>
 <div class="row"><span>Heading <code>16 / 600</code></span><div style="font-size:16px;font-weight:600">Refund policy by plan</div></div>
 <div class="row"><span>Body <code>16 / 400 / 1.6</code></span><div style="font-size:16px;line-height:1.6">Annual plans are refundable within 30 days.<sup style="color:var(--accent)">1</sup></div></div>
 <div class="row"><span>Label <code>12 / 500 / muted</code></span><div style="font-size:12px;font-weight:500;color:var(--muted)">Thought for 4s</div></div>
 <div class="row"><span>Mono <code>13 / 400</code></span><div style="font:13px 'JetBrains Mono',monospace">{"query": "refund annual"}</div></div>
</div>
</body></html>
```

```html example UI sheet
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--ground:#FAFAF9;--surface:#FFFFFF;--sunken:#F2F1EE;--ink:#1C1B19;--muted:#6B6862;--line:#E4E2DD;--accent:#2F5BD3;--run:#8F5E12;--ok:#24733E;--err:#C2412D}
*{box-sizing:border-box}
body{margin:0;padding:28px;background:var(--ground);color:var(--ink);font:14px/1.5 'IBM Plex Sans',system-ui,sans-serif}
.lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
h2{font-size:20px;font-weight:600;margin:0 0 18px;letter-spacing:-.01em}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
.cell{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-width:0}
.cell>.lbl{margin:0}
.state{font-size:12px;color:var(--muted)}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.col{display:flex;flex-direction:column;gap:6px}
button{font:500 13px 'IBM Plex Sans',sans-serif;border-radius:8px;padding:8px 14px;cursor:pointer;border:1px solid transparent;transition:background .15s,border-color .15s;color:var(--ink)}
.pri{background:var(--ink);color:#fff}.pri:hover{background:#3a3833}
.sec{background:var(--surface);border-color:var(--line)}.sec:hover{border-color:#c9c6bf}
.dis{background:var(--sunken);color:#a8a59f;cursor:not-allowed}
button:focus-visible,input:focus-visible,[role=slider]:focus-visible,input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.ib{width:34px;height:34px;padding:0;display:inline-grid;place-items:center;background:var(--surface);border-color:var(--line)}
.ib:hover{border-color:#c9c6bf}.ib.round{border-radius:50%}.ib.solid{background:var(--ink);border-color:var(--ink);color:#fff}
.ib svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.sw{width:36px;height:20px;border-radius:999px;background:var(--line);padding:0;border:0;position:relative}
.sw::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(28,27,25,.25);transition:transform .15s}
.sw[aria-checked=true]{background:var(--ink)}.sw[aria-checked=true]::after{transform:translateX(16px)}
.tg{display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:13px}
.seg{display:inline-flex;background:var(--sunken);border:1px solid var(--line);border-radius:8px;padding:2px}
.seg button{padding:5px 12px;border-radius:6px;font-weight:500;color:var(--muted);background:transparent}
.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 0 0 1px var(--line)}
.sl{display:flex;align-items:center;gap:12px}
input[type=range]{flex:1;accent-color:var(--ink);margin:0}
.val{font:500 12px "JetBrains Mono",monospace;min-width:34px;text-align:right}
.dialw{display:flex;align-items:center;gap:16px}
.dial{width:64px;height:64px;border-radius:50%;background:var(--surface);border:1px solid var(--line);position:relative;cursor:grab;touch-action:none;flex:none}
.dial svg{position:absolute;inset:-1px;width:64px;height:64px}
.dial i{position:absolute;left:50%;top:6px;width:2px;height:14px;margin-left:-1px;background:var(--ink);border-radius:1px;transform-origin:1px 26px}
input[type=text]{font:14px 'IBM Plex Sans',sans-serif;width:100%;padding:9px 12px 9px 32px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink)}
input::placeholder{color:#9a978f}
.fw{position:relative}.fw svg{position:absolute;left:10px;top:50%;width:14px;height:14px;margin-top:-7px;stroke:var(--muted);fill:none;stroke-width:1.8;stroke-linecap:round}
.focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px rgba(47,91,211,.15)}
.card{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
.card .img{height:96px;background:var(--sunken);border-bottom:1px solid var(--line);display:block}
.card .bd{padding:10px 12px;display:flex;flex-direction:column;gap:6px}
.card b{font-size:14px;font-weight:600}
.card a{font-size:13px;color:var(--accent);text-decoration:none;font-weight:500}
.card a:hover{text-decoration:underline}
.cal{font-size:12px}
.cal .hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-weight:600;font-size:13px}
.cal table{width:100%;border-collapse:collapse;text-align:center}
.cal th{color:var(--muted);font-weight:500;padding:3px 0;font-size:11px}
.cal td{padding:2px 0}
.cal td span{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:6px;font-variant-numeric:tabular-nums}
.cal td.o span{color:#b4b1aa}
.cal td.t span{box-shadow:inset 0 0 0 1px var(--line)}
.cal td.s span{background:var(--ink);color:#fff;font-weight:600}
.tag{display:inline-flex;align-items:center;gap:6px;font:500 12px 'IBM Plex Sans',sans-serif;padding:3px 9px;border-radius:999px;border:1px solid var(--line);background:var(--surface)}
.dot{width:7px;height:7px;border-radius:50%}
.msg{display:flex;gap:8px;align-items:flex-start;font-size:13px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--sunken)}
.msg .dot{margin-top:6px;flex:none}
code{font:12px "JetBrains Mono",monospace}
@media(max-width:420px){body{padding:18px 16px}}
</style></head><body>
<p class="lbl">03 / UI sheet</p>
<h2>Every basic part, quiet by default</h2>
<div class="grid">

 <div class="cell" data-ui="button"><p class="lbl">Button</p>
  <div class="row">
   <div class="col"><button class="pri">Send</button><span class="state">Primary</span></div>
   <div class="col"><button class="sec">Regenerate</button><span class="state">Secondary</span></div>
   <div class="col"><button class="dis" disabled>Send</button><span class="state">Disabled</span></div>
  </div>
 </div>

 <div class="cell" data-ui="icon-button"><p class="lbl">Icon button</p>
  <div class="row">
   <div class="col"><button class="ib" aria-label="Copy answer"><svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg></button><span class="state">Copy</span></div>
   <div class="col"><button class="ib" aria-label="Retry"><svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.3-5.6"/><path d="M4 4v4h4"/></svg></button><span class="state">Retry</span></div>
   <div class="col"><button class="ib round" aria-label="Attach file"><svg viewBox="0 0 24 24"><path d="M20 11.5 12.3 19a5 5 0 0 1-7-7l8-8a3.3 3.3 0 0 1 4.7 4.7l-8 8a1.7 1.7 0 0 1-2.4-2.4l7.3-7.3"/></svg></button><span class="state">Attach</span></div>
   <div class="col"><button class="ib round solid" aria-label="Send message"><svg viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></svg></button><span class="state">Send</span></div>
  </div>
 </div>

 <div class="cell" data-ui="toggle"><p class="lbl">Toggle</p>
  <div class="tg"><span>Web search</span><button class="sw" role="switch" aria-checked="true" aria-label="Web search"></button></div>
  <div class="tg"><span>Show reasoning by default</span><button class="sw" role="switch" aria-checked="false" aria-label="Show reasoning by default"></button></div>
  <span class="state">On / Off: click to switch</span>
 </div>

 <div class="cell" data-ui="segmented"><p class="lbl">Segmented</p>
  <div class="seg" role="group" aria-label="Reasoning effort"><button aria-pressed="false">Low</button><button aria-pressed="true">Medium</button><button aria-pressed="false">High</button></div>
  <span class="state">Reasoning effort, one selected</span>
 </div>

 <div class="cell" data-ui="slider"><p class="lbl">Slider</p>
  <div class="sl"><input type="range" min="0" max="2" step="0.1" value="0.7" id="temp" aria-label="Temperature"><span class="val" id="tempv">0.7</span></div>
  <span class="state">Temperature</span>
 </div>

 <div class="cell" data-ui="dial"><p class="lbl">Dial</p>
  <div class="dialw">
   <div class="dial" id="dial" role="slider" tabindex="0" aria-label="Max length" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60">
    <svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="29" fill="none" stroke="#E4E2DD" stroke-width="2" stroke-dasharray="137 46" transform="rotate(135 32 32)"/><circle id="arc" cx="32" cy="32" r="29" fill="none" stroke="#1C1B19" stroke-width="2" stroke-dasharray="0 183" transform="rotate(135 32 32)"/></svg>
    <i id="needle"></i>
   </div>
   <div class="col"><span class="val" id="dialv" style="text-align:left;font-size:14px">600</span><span class="state">Max tokens: drag or use arrows</span></div>
  </div>
 </div>

 <div class="cell" data-ui="input"><p class="lbl">Input</p>
  <div class="fw"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><input type="text" placeholder="Search chats" aria-label="Search chats"></div>
  <span class="state">Empty</span>
  <div class="fw"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><input type="text" class="focus" value="refund annual" aria-label="Search chats, focused"></div>
  <span class="state">Focused</span>
 </div>

 <div class="cell" data-ui="card"><p class="lbl">Card</p>
  <div class="card">
   <svg class="img" viewBox="0 0 280 96" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="280" height="96" fill="#F2F1EE"/><rect x="24" y="18" width="104" height="60" rx="6" fill="#fff" stroke="#E4E2DD"/><rect x="34" y="30" width="56" height="6" rx="3" fill="#1C1B19"/><rect x="34" y="44" width="80" height="4" rx="2" fill="#E4E2DD"/><rect x="34" y="54" width="70" height="4" rx="2" fill="#E4E2DD"/><rect x="34" y="64" width="40" height="4" rx="2" fill="#2F5BD3"/><rect x="142" y="18" width="114" height="60" rx="6" fill="#fff" stroke="#E4E2DD"/><path d="M154 66l22-20 16 12 18-22 34 30" fill="none" stroke="#6B6862" stroke-width="2"/></svg>
   <div class="bd"><b>billing/refunds.md</b><span class="state">Help center, cited in 3 answers</span><a href="#">Open source</a></div>
  </div>
 </div>

 <div class="cell" data-ui="calendar"><p class="lbl">Calendar</p>
  <div class="cal">
   <div class="hd"><span>October 2026</span><span class="row" style="gap:4px"><button class="ib" style="width:26px;height:26px" aria-label="Previous month"><svg viewBox="0 0 24 24"><path d="m15 6-6 6 6 6"/></svg></button><button class="ib" style="width:26px;height:26px" aria-label="Next month"><svg viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></button></span></div>
   <table><tr><th>M</th><th>T</th><th>W</th><th>T</th><th>F</th><th>S</th><th>S</th></tr>
   <tr><td class="o"><span>28</span></td><td class="o"><span>29</span></td><td class="o"><span>30</span></td><td><span>1</span></td><td><span>2</span></td><td><span>3</span></td><td><span>4</span></td></tr>
   <tr><td><span>5</span></td><td><span>6</span></td><td><span>7</span></td><td><span>8</span></td><td><span>9</span></td><td><span>10</span></td><td><span>11</span></td></tr>
   <tr><td><span>12</span></td><td><span>13</span></td><td><span>14</span></td><td><span>15</span></td><td><span>16</span></td><td><span>17</span></td><td><span>18</span></td></tr>
   <tr><td><span>19</span></td><td><span>20</span></td><td><span>21</span></td><td><span>22</span></td><td class="t"><span>23</span></td><td><span>24</span></td><td><span>25</span></td></tr>
   <tr><td><span>26</span></td><td><span>27</span></td><td><span>28</span></td><td><span>29</span></td><td class="s"><span>30</span></td><td><span>31</span></td><td class="o"><span>1</span></td></tr></table>
  </div>
  <span class="state">Today outlined, picked date filled: chat history jump</span>
 </div>

 <div class="cell" data-ui="status"><p class="lbl">Status</p>
  <div class="row">
   <span class="tag"><span class="dot" style="background:var(--ok)"></span>Done</span>
   <span class="tag"><span class="dot" style="background:var(--run)"></span>Running</span>
   <span class="tag"><span class="dot" style="background:var(--err)"></span>Failed</span>
  </div>
  <div class="msg"><span class="dot" style="background:var(--ok)"></span><span><code>search_docs</code> returned 3 sources</span></div>
  <div class="msg"><span class="dot" style="background:var(--run)"></span><span>Rate limited, retrying in 4s</span></div>
  <div class="msg"><span class="dot" style="background:var(--err)"></span><span><code>issue_refund</code> failed: needs a billing admin</span></div>
  <span class="state">Success, warning, error: tool state only</span>
 </div>

</div>
<script>
document.querySelectorAll('.sw').forEach(function(b){b.onclick=function(){b.setAttribute('aria-checked',b.getAttribute('aria-checked')!=='true');};});
document.querySelectorAll('.seg').forEach(function(g){g.querySelectorAll('button').forEach(function(b){b.onclick=function(){g.querySelectorAll('button').forEach(function(x){x.setAttribute('aria-pressed',x===b);});};});});
var t=document.getElementById('temp'),tv=document.getElementById('tempv');t.oninput=function(){tv.textContent=(+t.value).toFixed(1);};
var d=document.getElementById('dial'),n=document.getElementById('needle'),a=document.getElementById('arc'),dv=document.getElementById('dialv'),v=60;
function setD(x){v=Math.max(0,Math.min(100,Math.round(x)));n.style.transform='rotate('+(-135+v*2.7)+'deg)';a.setAttribute('stroke-dasharray',(v*1.37)+' 183');d.setAttribute('aria-valuenow',v);dv.textContent=Math.round((100+v*39)/10)*10;}
setD(v);
var drag=false,y0=0,v0=0;
d.onpointerdown=function(e){drag=true;y0=e.clientY;v0=v;d.setPointerCapture(e.pointerId);};
d.onpointermove=function(e){if(drag)setD(v0+(y0-e.clientY)*0.6);};
d.onpointerup=function(){drag=false;};
d.onkeydown=function(e){if(e.key==='ArrowUp'||e.key==='ArrowRight'){setD(v+5);e.preventDefault();}if(e.key==='ArrowDown'||e.key==='ArrowLeft'){setD(v-5);e.preventDefault();}};
</script>
</body></html>
```

```html example In use | wide
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--ground:#FAFAF9;--surface:#FFFFFF;--sunken:#F2F1EE;--ink:#1C1B19;--muted:#6B6862;--line:#E4E2DD;--accent:#2F5BD3;--run:#B7791F;--ok:#2F8F4E;--err:#C2412D;--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'JetBrains Mono',ui-monospace,monospace}
*{box-sizing:border-box}
body{margin:0;padding:20px;background:var(--ground);color:var(--ink);font:15px/1.5 var(--sans)}
.lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
h2{font-size:20px;font-weight:600;margin:0 0 4px;letter-spacing:-.01em}
.note{color:var(--muted);margin:0 0 14px;font-size:13px;max-width:70ch}
.device{display:inline-flex;gap:2px;padding:3px;border:1px solid var(--line);border-radius:9px;background:var(--surface);margin-bottom:12px}
.device button{font:500 13px var(--sans);border:0;background:none;border-radius:6px;padding:6px 12px;color:var(--muted);cursor:pointer}
.device button[aria-pressed=true]{background:var(--ink);color:#fff}
.frame{margin:0 auto;width:100%;max-width:1100px;height:620px;border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--ground)}
.frame.phone{max-width:390px;height:700px;border-radius:28px}

/* The app. Container queries stand in for media queries so the demo can switch size;
   in a real app use the same rules with @media and height:100dvh on the shell. */
.app{container-type:inline-size;height:100%;position:relative}
.shell{display:grid;grid-template-columns:auto minmax(0,1fr);height:100%}
.side{width:264px;background:var(--sunken);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.side.closed{display:none}
.side-top{display:flex;flex-direction:column;gap:8px;padding:12px}
.newchat{display:flex;align-items:center;gap:8px;font:500 14px var(--sans);background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:9px 12px;cursor:pointer;color:var(--ink);min-height:40px}
.newchat kbd{margin-left:auto;font:11px var(--mono);color:var(--muted)}
.search{font:14px var(--sans);border:1px solid var(--line);border-radius:10px;padding:8px 12px;background:var(--surface);color:var(--ink);min-height:40px}
.hist{overflow-y:auto;overscroll-behavior:contain;padding:0 8px 12px;flex:1}
.hist h3{font-size:12px;font-weight:600;color:var(--muted);margin:14px 8px 4px}
.hist a{display:block;padding:8px 10px;border-radius:8px;color:var(--ink);text-decoration:none;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:36px}
.hist a:hover{background:#E9E7E2}.hist a[aria-current=page]{background:var(--surface);box-shadow:0 0 0 1px var(--line)}
.main{display:grid;grid-template-rows:auto minmax(0,1fr) auto;grid-template-columns:minmax(0,1fr);min-width:0;min-height:0;position:relative;background:var(--ground)}
.top{display:flex;align-items:center;gap:8px;height:52px;padding:0 12px;border-bottom:1px solid var(--line)}
.top h1{font-size:15px;font-weight:600;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.ib{width:40px;height:40px;display:grid;place-items:center;border:0;background:none;border-radius:10px;color:var(--ink);cursor:pointer;flex:none}
.ib:hover{background:var(--sunken)}
.ib svg{width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.thread{overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;min-height:0}
.col{max-width:760px;margin:0 auto;padding:24px 20px 8px}
.turn{display:flex;flex-direction:column;gap:8px;margin-bottom:28px}
.user{align-self:flex-end;max-width:min(70%,560px);background:var(--sunken);border-radius:18px;padding:10px 16px;white-space:pre-wrap;overflow-wrap:anywhere}
.bot{max-width:100%;overflow-wrap:anywhere}
.bot p{margin:0 0 12px}.bot ul{margin:0 0 12px;padding-left:22px}.bot li{margin:2px 0}
.bot pre{margin:0 0 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);overflow:hidden}
.bot pre header{display:flex;justify-content:space-between;align-items:center;font:12px var(--mono);color:var(--muted);padding:6px 6px 6px 12px;border-bottom:1px solid var(--line)}
.bot pre code{display:block;padding:12px;font:13px/1.55 var(--mono);overflow-x:auto;white-space:pre}
.caret::after{content:"";display:inline-block;width:7px;height:1.05em;margin-left:2px;vertical-align:-2px;background:var(--ink);animation:blink 1s steps(2) infinite}
@keyframes blink{50%{opacity:0}}
.thinking{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:14px}
.thinking i{width:6px;height:6px;border-radius:50%;background:var(--muted);animation:pulse 1.2s infinite ease-in-out}
.thinking i:nth-child(2){animation-delay:.15s}.thinking i:nth-child(3){animation-delay:.3s}
@keyframes pulse{0%,80%,100%{opacity:.25}40%{opacity:1}}
.acts{display:flex;gap:2px;align-items:center;color:var(--muted)}
.acts button{font:500 13px var(--sans);display:inline-flex;align-items:center;gap:6px;border:0;background:none;color:var(--muted);border-radius:8px;padding:0 8px;height:32px;cursor:pointer}
.acts button:hover{background:var(--sunken);color:var(--ink)}
.acts svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.turn .acts{opacity:0;transition:opacity .12s}.turn:hover .acts,.turn:focus-within .acts,.turn:last-child .acts{opacity:1}
.stopped{font-size:13px;color:var(--muted)}
.empty{display:grid;place-items:center;min-height:100%;padding:24px 20px;text-align:center}
.empty h2{font-size:22px;margin-bottom:6px}.empty p{color:var(--muted);margin:0 0 18px}
.sugg{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;max-width:560px;margin:0 auto}
.sugg button{font:14px var(--sans);text-align:left;border:1px solid var(--line);background:var(--surface);border-radius:12px;padding:10px 14px;cursor:pointer;color:var(--ink);min-height:44px}
.sugg button:hover{border-color:#cfccc5}
.jump{position:absolute;left:50%;bottom:calc(var(--compose-h,96px) + 12px);transform:translateX(-50%);display:none;align-items:center;gap:6px;font:500 13px var(--sans);border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:999px;padding:0 14px;height:36px;box-shadow:0 4px 14px rgb(0 0 0 / .08);cursor:pointer}
.jump.on{display:inline-flex}
.compose{padding:8px 16px calc(12px + env(safe-area-inset-bottom));background:var(--ground)}
.box{max-width:760px;margin:0 auto;display:flex;align-items:flex-end;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:22px;padding:6px 6px 6px 8px}
.box:focus-within{border-color:#bdb9b1;box-shadow:0 0 0 3px rgb(47 91 211 / .12)}
textarea{flex:1;border:0;outline:0;resize:none;background:none;color:var(--ink);font:16px/1.5 var(--sans);padding:7px 4px;max-height:200px;min-height:38px;field-sizing:content}
.send{width:38px;height:38px;border-radius:50%;border:0;display:grid;place-items:center;background:var(--ink);color:#fff;cursor:pointer;flex:none}
.send[aria-disabled=true]{background:#d9d6cf;cursor:default}
.send svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.hint{max-width:760px;margin:6px auto 0;font-size:12px;color:var(--muted);text-align:center}
.scrim{display:none}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
textarea:focus-visible{outline:0}

/* Phone: one column, sidebar becomes a drawer, every target 44px, actions always shown. */
@container (max-width: 767px){
  .shell{grid-template-columns:minmax(0,1fr)}
  .side{position:absolute;inset:0 auto 0 0;z-index:20;width:min(85%,320px);transform:translateX(-102%);transition:transform .22s ease;box-shadow:8px 0 24px rgb(0 0 0 / .12)}
  .side.closed{display:flex}
  .side.open{transform:none}
  .scrim{position:absolute;inset:0;z-index:15;background:rgb(28 27 25 / .35);border:0}
  .scrim.on{display:block}
  .col{padding:16px 16px 8px}
  .user{max-width:85%}
  .turn .acts{opacity:1}
  .acts button{height:44px;min-width:44px;padding:0 10px}
  .hist a{min-height:44px;padding:12px 10px}
  .send{width:44px;height:44px}
  .sugg{grid-template-columns:1fr}
  .hint,.newchat kbd{display:none}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style></head><body>
<p class="lbl">04 / In use</p>
<h2>A reference chat that works on a phone and a desktop</h2>
<p class="note">This is the spec built, and it works: send a message, stop it mid-stream, scroll up while it streams, open the history. Switch the frame size to see the phone layout.</p>
<div class="device" role="group" aria-label="Frame size"><button aria-pressed="true" data-w="desk">Desktop</button><button aria-pressed="false" data-w="phone">Phone</button></div>
<div class="frame" id="frame"><div class="app">
 <div class="shell">
  <aside class="side" id="side" aria-label="Chat history">
   <div class="side-top">
    <button class="newchat" id="newchat"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>New chat<kbd>Ctrl Shift O</kbd></button>
    <input class="search" type="search" placeholder="Search chats" aria-label="Search chats">
   </div>
   <nav class="hist" aria-label="Conversations">
    <h3>Today</h3><a href="#" aria-current="page">Sticky composer on iOS</a><a href="#">Fix flaky upload test</a>
    <h3>Yesterday</h3><a href="#">Compare three CSS grid layouts for the dashboard</a><a href="#">Draft release notes</a>
    <h3>Last 7 days</h3><a href="#">Postgres index for search</a><a href="#">Weekend trip packing list</a><a href="#">Rename the billing module</a>
   </nav>
  </aside>
  <button class="scrim" id="scrim" aria-label="Close history" tabindex="-1"></button>
  <section class="main">
   <header class="top">
    <button class="ib" id="menu" aria-label="Toggle history" aria-controls="side" aria-expanded="true"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
    <h1 id="title">New chat</h1>
    <button class="ib" id="newchat2" aria-label="New chat"><svg viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg></button>
   </header>
   <div class="thread" id="thread" role="log" aria-label="Conversation" tabindex="-1"><div class="col" id="col"></div></div>
   <button class="jump" id="jump"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M6 13l6 6 6-6"/></svg>Jump to latest</button>
   <form class="compose" id="compose">
    <div class="box">
     <label class="sr" for="input">Message</label>
     <textarea id="input" rows="1" placeholder="Message the assistant" enterkeyhint="send" autocapitalize="sentences" spellcheck="true"></textarea>
     <button class="send" id="send" type="submit" aria-label="Send" aria-disabled="true"><svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
    </div>
    <p class="hint">Enter to send, Shift+Enter for a new line</p>
   </form>
  </section>
 </div>
 <div class="sr" aria-live="polite" id="live"></div>
</div></div>
<script>
(function(){
var $=function(id){return document.getElementById(id)};
var frame=$('frame'),side=$('side'),scrim=$('scrim'),menu=$('menu'),thread=$('thread'),col=$('col'),input=$('input'),send=$('send'),jump=$('jump'),live=$('live'),compose=$('compose');
var ICON={copy:'<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',redo:'<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>',
  send:'<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',stop:'<svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="1.5" fill="currentColor" stroke="none"/></svg>'};
var ANSWER=[['p','On iOS the keyboard overlays the page instead of resizing it, so a composer pinned with position: fixed ends up hidden behind it.'],
  ['p','Size the app shell to the visual viewport instead:'],
  ['ul',['Read window.visualViewport.height on resize and scroll','Set it as the shell height, and keep the thread as the only scroller','Keep the textarea at 16px or more, or iOS zooms the page when it gets focus']],
  ['code','js','const vv = window.visualViewport\nconst fit = () => {\n  document.documentElement.style.setProperty(\'--app-h\', vv.height + \'px\')\n}\nvv.addEventListener(\'resize\', fit)\nfit()'],
  ['p','Chrome and Firefox on Android can do this natively with interactive-widget=resizes-content in the viewport meta tag, so the script only matters for Safari.']];
var app=document.querySelector('.app');
var phone=function(){return app.clientWidth<768};                   // same breakpoint as the CSS
var busy=null,following=false;

// Frame size toggle (demo only).
document.querySelectorAll('.device button').forEach(function(b){b.onclick=function(){
  document.querySelectorAll('.device button').forEach(function(x){x.setAttribute('aria-pressed',x===b)});
  frame.classList.toggle('phone',b.dataset.w==='phone');closeSide();syncSide();}});

// Sidebar: a column on desktop (toggle with the menu button or Ctrl+B), a drawer on a phone.
function syncSide(){var open=phone()?side.classList.contains('open'):!side.classList.contains('closed');menu.setAttribute('aria-expanded',open)}
function closeSide(){side.classList.remove('open');scrim.classList.remove('on');syncSide()}
menu.onclick=function(){if(phone()){var o=side.classList.toggle('open');scrim.classList.toggle('on',o);if(o)side.querySelector('.newchat').focus()}else side.classList.toggle('closed');syncSide()};
scrim.onclick=function(){closeSide();menu.focus()};
side.addEventListener('keydown',function(e){if(e.key==='Escape'&&phone()){closeSide();menu.focus()}});
side.querySelectorAll('.hist a').forEach(function(a){a.onclick=function(e){e.preventDefault();side.querySelectorAll('.hist a').forEach(function(x){x.removeAttribute('aria-current')});a.setAttribute('aria-current','page');$('title').textContent=a.textContent;if(phone())closeSide()}});

// Composer: auto-grow (field-sizing where supported, script elsewhere), Enter rules, send <-> stop.
function grow(){if(CSS.supports('field-sizing','content'))return;input.style.height='auto';input.style.height=Math.min(input.scrollHeight,200)+'px'}
function canSend(){return !busy&&input.value.trim().length>0}
function syncSend(){
  if(busy){send.innerHTML=ICON.stop;send.setAttribute('aria-label','Stop generating');send.setAttribute('aria-disabled','false')}
  else{send.innerHTML=ICON.send;send.setAttribute('aria-label','Send');send.setAttribute('aria-disabled',String(!canSend()))}
  frame.style.setProperty('--compose-h',compose.offsetHeight+'px')}
input.addEventListener('input',function(){grow();syncSend()});
input.addEventListener('keydown',function(e){
  if(e.isComposing||e.keyCode===229)return;                        // an IME is confirming a word, not sending
  if(e.key==='Enter'&&!e.shiftKey&&!matchMedia('(pointer:coarse)').matches){e.preventDefault();submit()}  // touch keyboards: Enter is a new line
  if(e.key==='Escape'&&busy)stop()});
send.addEventListener('pointerdown',function(e){e.preventDefault()});  // keep the keyboard up on a phone
compose.onsubmit=function(e){e.preventDefault();busy?stop():submit()};
document.addEventListener('keydown',function(e){
  var mod=e.metaKey||e.ctrlKey;
  if(mod&&e.key.toLowerCase()==='b'){e.preventDefault();menu.click()}
  if(mod&&e.shiftKey&&e.key.toLowerCase()==='o'){e.preventDefault();newChat()}});

// Scroll: on send, the new question moves to the top and the answer streams below it.
// The page never chases the stream; "Jump to latest" appears when there is more below.
function atBottom(){return thread.scrollHeight-thread.scrollTop-thread.clientHeight<48}
thread.addEventListener('scroll',function(){jump.classList.toggle('on',!atBottom())});
jump.onclick=function(){thread.scrollTo({top:thread.scrollHeight,behavior:matchMedia('(prefers-reduced-motion:reduce)').matches?'auto':'smooth'})};
function spacer(turn){turn.style.minHeight=(thread.clientHeight-32)+'px'}

function el(tag,cls,html){var n=document.createElement(tag);if(cls)n.className=cls;if(html!=null)n.innerHTML=html;return n}
function esc(s){return s.replace(/[&<>]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;'}[c]})}
function empty(){col.innerHTML='';var e=el('div','empty');e.innerHTML='<div><h2>What are we working on?</h2><p>Ask anything, or start from one of these.</p><div class="sugg"></div></div>';
  ['Why does my chat input hide behind the iOS keyboard?','Write release notes from these commits','Compare three layouts for a settings page','Explain this stack trace'].forEach(function(t){var b=el('button',null,esc(t));b.onclick=function(){input.value=t;submit()};e.querySelector('.sugg').appendChild(b)});
  col.appendChild(e);thread.style.overflowY='auto'}
function newChat(){if(busy)stop();empty();$('title').textContent='New chat';input.value='';grow();syncSend();if(!phone())input.focus();closeSide()}
$('newchat').onclick=newChat;$('newchat2').onclick=newChat;

function submit(){
  if(!canSend())return;
  var text=input.value.trim();input.value='';grow();
  if(col.querySelector('.empty'))col.innerHTML='';
  col.querySelectorAll('.turn').forEach(function(t){t.style.minHeight=''});
  var turn=el('div','turn');turn.appendChild(el('div','user',esc(text)));col.appendChild(turn);
  if($('title').textContent==='New chat')$('title').textContent=text.length>48?text.slice(0,46)+'…':text;
  spacer(turn);
  thread.scrollTo({top:turn.offsetTop-12,behavior:'auto'});
  var bot=el('div','bot');var think=el('div','thinking','<i></i><i></i><i></i><span>Thinking</span>');turn.appendChild(think);
  busy={turn:turn,bot:bot,timer:setTimeout(function(){think.remove();turn.appendChild(bot);stream(bot)},700)};syncSend();
}
function stream(bot){
  var bi=0,ci=0,cur=null,last=performance.now();
  function step(now){
    if(!busy)return;
    var n=Math.max(1,Math.round((now-last)/16*3));last=now;          // a few characters per frame, batched per animation frame
    while(n-->0&&bi<ANSWER.length){
      var b=ANSWER[bi];
      if(!cur){cur=b[0]==='code'?el('pre',null,'<header><span>'+b[1]+'</span></header><code></code>'):el(b[0]);bot.appendChild(cur);if(b[0]==='ul')cur.appendChild(el('li'))}
      var src=b[0]==='ul'?b[1].join('\n'):b[0]==='code'?b[2]:b[1];
      if(ci<src.length){var c=src[ci++];
        if(b[0]==='ul'){if(c==='\n')cur.appendChild(el('li'));else cur.lastChild.textContent+=c}
        else (b[0]==='code'?cur.querySelector('code'):cur).textContent+=c}
      else{bi++;ci=0;cur=null}}
    bot.querySelectorAll('.caret').forEach(function(x){x.classList.remove('caret')});
    var tail=bot.lastElementChild;if(tail){(tail.tagName==='UL'?tail.lastChild:tail.tagName==='PRE'?tail.querySelector('code'):tail).classList.add('caret')}
    jump.classList.toggle('on',!atBottom());
    if(bi<ANSWER.length)requestAnimationFrame(step);else finish(false)}
  requestAnimationFrame(step)}
function finish(stopped){
  var b=busy;busy=null;if(!b)return;clearTimeout(b.timer);
  b.turn.querySelectorAll('.thinking').forEach(function(x){x.remove()});
  b.bot.querySelectorAll('.caret').forEach(function(x){x.classList.remove('caret')});
  b.bot.querySelectorAll('pre header').forEach(function(h){if(h.querySelector('button'))return;var c=el('button',null,ICON.copy+'Copy');c.className='';c.setAttribute('aria-label','Copy code');c.onclick=function(){c.lastChild.textContent='Copied';setTimeout(function(){c.lastChild.textContent='Copy'},1500)};var w=el('span','acts');w.appendChild(c);h.appendChild(w)});
  if(stopped)b.turn.appendChild(el('div','stopped','Stopped. The answer above is incomplete.'));
  var acts=el('div','acts');
  var cp=el('button',null,ICON.copy+'<span>Copy</span>');cp.setAttribute('aria-label','Copy answer');cp.onclick=function(){cp.lastChild.textContent='Copied';setTimeout(function(){cp.lastChild.textContent='Copy'},1500)};
  var rg=el('button',null,ICON.redo+'<span>Regenerate</span>');rg.onclick=function(){var q=b.turn.querySelector('.user').textContent;b.turn.remove();input.value=q;submit()};
  acts.appendChild(cp);acts.appendChild(rg);b.turn.appendChild(acts);
  live.textContent=stopped?'Stopped.':'Answer ready.';syncSend();if(!phone())input.focus()}
function stop(){if(busy)finish(true)}

empty();syncSend();grow();syncSide();
})();
</script>
</body></html>
```

```html example Do and don't
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--ground:#FAFAF9;--surface:#FFFFFF;--sunken:#F2F1EE;--ink:#1C1B19;--muted:#6B6862;--line:#E4E2DD;--accent:#2F5BD3;--ok:#24733E;--err:#C2412D}
*{box-sizing:border-box}
body{margin:0;padding:28px;background:var(--ground);color:var(--ink);font:14px/1.5 'IBM Plex Sans',system-ui,sans-serif}
.lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
h2{font-size:20px;font-weight:600;margin:0 0 18px;letter-spacing:-.01em}
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}
.col h3{font-size:14px;margin:0 0 10px;display:flex;align-items:center;gap:8px}
.mark{width:18px;height:18px;border-radius:50%;color:#fff;font-size:12px;display:inline-grid;place-items:center}
.sample{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:14px;height:190px;overflow:hidden;font-size:13px}
.tool{border:1px solid var(--line);border-radius:8px;background:var(--sunken);padding:6px 10px;font-size:12px;color:var(--muted);margin-bottom:10px}
.think{color:var(--muted);font-size:12px;line-height:1.5;border-left:2px solid var(--line);padding-left:10px}
code{font:12px "JetBrains Mono",monospace}
sup{color:var(--accent);font-weight:600}
ul{margin:12px 0 0;padding-left:18px;font-size:13px}li{margin-bottom:5px}
</style></head><body>
<p class="lbl">05 / Do and don't</p>
<h2>Answer first, the work one click away</h2>
<div class="cols">
 <div class="col">
  <h3><span class="mark" style="background:var(--ok)">&#10003;</span>Do</h3>
  <div class="sample">
   <div class="tool"><code>search_docs</code> Searched 3 sources, thought for 4s</div>
   <p style="margin:0;font-size:14px"><b>Yes.</b> Annual plans are refundable within 30 days.<sup>1</sup> You have 18 days left.</p>
  </div>
  <ul><li>Collapse reasoning and tool calls above the answer.</li><li>Lead with the answer; cite only sources actually used.</li><li>Release auto-scroll the moment the user scrolls up.</li><li>Show an approval card before anything destructive.</li></ul>
 </div>
 <div class="col">
  <h3><span class="mark" style="background:var(--err)">&#10005;</span>Don't</h3>
  <div class="sample">
   <div class="think">Okay, the user is asking about refunds. First I should figure out which plan they are on. They said annual. Let me recall the policy. There might be different rules for monthly versus annual, and I also need to consider whether proration applies, and whether the upgrade date counts as the purchase date, which it probably does, so then twelve days...</div>
   <p style="margin:10px 0 0;color:var(--muted)">Answer below the fold</p>
  </div>
  <ul><li>Open raw chain-of-thought by default.</li><li>Add source chips that don't support the sentence.</li><li>Drag the reader back down while they read.</li><li>Confirm every harmless read until nobody reads confirms.</li></ul>
 </div>
</div>
</body></html>
```

## References

- [aicss: UI components for AI agents](https://www.aicss.dev/): copy-paste blocks for everything an agent shows mid-conversation: thinking states, tool calls, file diffs, streaming text, inline citations, tables, approval cards.
- [prompt-kit: AI SDK UI](https://www.prompt-kit.com/ai-sdk): provider-agnostic chat components (prompt input, chat container, scroll button, reasoning, tool, source). Also publishes an llms.txt.
- [AI Elements (Vercel)](https://elements.ai-sdk.dev/): shadcn-based components for message parts, reasoning panels, tool calls and response actions on top of the AI SDK.
- [Introducing AI Elements (Vercel changelog)](https://vercel.com/changelog/introducing-ai-elements): the rationale and a useChat + Message example.
- [Streamdown](https://streamdown.ai/): a markdown renderer designed for streaming AI output, including unterminated blocks.
- [use-stick-to-bottom](https://github.com/stackblitz-labs/use-stick-to-bottom): the scroll behavior chat needs (stick, release on user scroll, scroll anchoring, spring animation).
- [The Shape of AI](https://www.shapeof.ai/): Emily Campbell's pattern library: wayfinders, inputs, tuners, governors, trust builders, identifiers.
- [The 6 Types of Conversations with Generative AI (NN/g)](https://www.nngroup.com/articles/AI-conversation-types/): diary-study research on how people actually converse with AI and what the UI should do for each type.
- [Guidelines for Human-AI Interaction (Microsoft HAX)](https://www.microsoft.com/en-us/haxtoolkit/ai-guidelines/): 18 evidence-based guidelines across initial use, interaction, when wrong, and over time.
- [Design Principles for Generative AI Applications (IBM, CHI 2024)](https://arxiv.org/abs/2401.14484): six principles including design for generative variability, appropriate trust and reliance, and imperfection.
- [People + AI Guidebook (Google PAIR)](https://pair.withgoogle.com/guidebook/): Google's guide to designing human-centered AI products.
- [assistant-ui](https://www.assistant-ui.com/): another open-source React library for AI chat interfaces, useful for comparison.
- [Vercel Chatbot (open source)](https://github.com/vercel/chatbot): a full reference app; its sidebar history shows date grouping, paged loading, delete confirmation, private/public sharing and auto-generated titles.
- [Sidebar (shadcn/ui)](https://ui.shadcn.com/docs/components/sidebar): a composable sidebar with offcanvas and icon-collapse modes, a mobile variant and a Cmd+B / Ctrl+B toggle.
- [prompt-kit blocks](https://www.prompt-kit.com/blocks): includes a "Sidebar with chat history" block grouped by Today, Yesterday and Last 7 days.
- [assistant-ui Thread list](https://www.assistant-ui.com/docs/ui/thread-list): conversation switching with search, active selection and thread actions.
- [Branches (Shape of AI)](https://www.shapeof.ai/patterns/branches): forking a conversation without losing the path back.
- [Incognito Mode (Shape of AI)](https://www.shapeof.ai/patterns/incognito-mode): sessions outside memory that don't clutter the sidebar.
- [Memory (Shape of AI)](https://www.shapeof.ai/patterns/memory): continuity across sessions, and the controls it needs.
- [VisualViewport (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport): the API behind the iOS keyboard fallback.
- [Control the viewport resize behavior on mobile with interactive-widget (HTMHell)](https://htmhell.dev/adventcalendar/2024/4/): what `resizes-content` does and why it exists.
- [interactive-widget=resizes-content (Can I use)](https://caniuse.com/mdn-html_elements_meta_name_viewport_interactive-widget_resizes-content): support data, including that iOS Safari doesn't have it.
- [WebKit bug 165004](https://bugs.webkit.org/show_bug.cgi?id=165004) and [keydown: isComposing (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event): why Enter during IME composition needs the `keyCode === 229` check in Safari.
- [Bluesky: don't send on IME Enter in Safari (GitHub)](https://github.com/bluesky-social/social-app/pull/4186): the same fix in a production app, with a Japanese IME test plan.
- [enterkeyhint (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/enterkeyhint): labelling the phone keyboard's Enter key as Send.
- [field-sizing (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/field-sizing): auto-growing textareas in CSS, with a script fallback where unsupported.
- [env() and safe areas (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/env): keeping the composer clear of the home indicator.
- [content-visibility (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility): skipping work for off-screen turns in long threads.
- [ARIA log role (MDN)](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role): the role for a conversation transcript.
- [Target size (WCAG 2.2)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) and [Accessibility (Apple HIG)](https://developer.apple.com/design/human-interface-guidelines/accessibility): the minimum target sizes, and the 44pt one Apple recommends for touch.
- [Anti-vibe-coded UI (ayeeye)](https://ayeeye.net/posts/claude-opus/anti-vibe-coded-ui): the hub's baseline design values. Chat UIs are mostly copy (buttons, errors, empty states, starter prompts), and its writing rules apply to all of it. This spec also takes its warning about defaults literally: the reference uses IBM Plex Sans rather than Inter, and every rule exists because of a specific failure, not because other chat apps happen to look that way.
