diff --git a/docs/decisions.md b/docs/decisions.md
index 786e117..16e5a84 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -3,6 +3,7 @@
Decisions that shape the app, one line each: `YYYY-MM-DD · decision · consequence`. When a decision changes, replace its line; Git keeps the old one. Agents read the section their task touches.
## Design
+- 2026-10-21 · A PRD section's copy button is opencode's (the owner, 2026-10-21: as close to opencode as possible, without cluttering the PRD; `MessageActionButton` and the text part's copy row in `packages/session-ui/src/message/message-content.tsx` and `components/message-part.css`, the code block's in `components/markdown.tsx` and `markdown.css`, `packages/ui/src/actions/icon-button/icon-button.css` and `overlays/tooltip/`, at 50c5fce, read 2026-10-21): an icon button of 24px with a 14px grey copy icon and no border, a faint background on hover and a stronger one while pressed; on a screen with hover it shows only while the pointer is over the section or focus is inside it, fading in over 0.15s, and on a touch screen it always shows; after the pointer rests on it for 0.4s, a small label "Copy section" shows 4px above it (11px, medium weight, on a raised dark surface without a border, appearing in 0.12s), and it closes when the button is pressed; after a copy the icon is a check mark for two seconds and the button's name is "Copied". Unlike opencode's: it sits beside the section's tally; when the browser refuses the clipboard it reads "Couldn't copy" instead of doing nothing; and screen readers hear "Copied" · Replaces the quiet "Copy" word beside the tally (slice 17 as planned on 2026-10-20). The tooltip is CSS of the app's own, since opencode's comes from a library for its own framework
- 2026-10-20 · The interface polish, slices 10b to 20 (the owner, 2026-10-20), from a study of the source of opencode's desktop app (https://github.com/anomalyco/opencode, MIT licence; `packages/app`, `packages/session-ui` and `packages/ui` at commit 50c5fce on branch `dev`, read 2026-10-20): its spacing and layout, a moving line while the AI works, a Stop button, the PRD showing what changed, "Jump to latest", an answer's searches folded into one line (its form below), words fading in, copying a section, the Inter typeface, opencode's tables and a sign that the model is thinking. Its ideas, not its code or its looks, inside the colour and shape rules below; CSS and the React the app already has, no animation library (opencode's springs use `motion`, and adding it needs the owner's OK); every animation stops under `prefers-reduced-motion`. The model's thinking shows as "Thinking…" and "Thought for 8s", never its words: gpt-oss's chain of thought isn't held to the safety standards of its final answers, and OpenAI recommends hiding it from end users (https://github.com/openai/harmony/blob/main/docs/format.md, read 2026-10-20 through Context7); since the chat model gives whole replies (slice 8a), the sign is the browser's own wait for each reply, and nothing in `src/server` changes (slice 20) · The owner wants the demo to feel as finished as opencode's app. Left out: automatic retries, which spend the shared allocation, and sounds, themes, prompt history and opencode's timeline settings, which serve long coding sessions rather than a short demo
- 2026-10-20 · An answer's searches show the way opencode's desktop app shows its search tools, as closely as the colour rules allow (the owner, 2026-10-20: full opencode compliance here, no extra components; `CurrentContextToolGroup` in `packages/session-ui/src/tools/tool-renderer.tsx`, `context-tool-group` in `packages/session-ui/src/components/message-part.css` and `packages/ui/src/components/collapsible.css`, at 50c5fce, read 2026-10-20): each search is a plain line that doesn't open, "Search" in medium weight, then the query and whose passages came back in grey, at 13px; two or more searches in a row fold into one line, "Used 3 searches", whose count rolls up as each search starts and which opens to their plain lines, 28px rows 8px apart, without indent or side line; the line's mark is a small triangle after its text, pointing right when closed and down when open, turning in 0.15s; the lines slide open in about 0.35s, without bounce; no time on the line; all movement stops under reduced motion. Unlike opencode's, whose mark shows only on hover, the mark shows faintly at rest, as a phone has no hover (the owner) · The owner found a cascade of opening lines bloated: opencode opens one layer, and its search lines never open. So the chat no longer shows a search's passages. The visitor checks the output in the PRD: each statement's sources open the pop-over with the quote, and the check's tally and objections show under it; whether the search finds the right passages is the measurement's question, not the visitor's. Replaces each step as one line that opens its passages (2026-10-17, 2026-10-18). Not taken: a line in the pop-over naming the search that found a quote (an idea for later, if the owner wants it), a passage count on each line like opencode's "(12 matches)", since the names say more to a visitor
- 2026-10-20 · The workspace's spacing and layout follow opencode's desktop app where it fits this app (slice 10b has the figures): a chat column of steady width beside a PRD pane that takes the rest, a page wider than 1280px, slimmer page chrome around the panes, a question closer to its own answer than to the turn before, tighter steps, list items further apart, and a message box that grows with its text · On a 1440px-wide screen the PRD's page gets 625px of its decided 42rem, the chat puts as much space between a question and its own answer as between turns, and the header, footer and padding take about 150px of the height. Not taken: opencode's 14px body text (its sessions are dense code; this chat is for reading, and its message box needs 16px so iOS doesn't zoom), its rounded panels (the PRD is a document page, not a card: 2026-10-18), its shadowed message box without a border (this one keeps its border, for contrast), and its reading column of up to 744px, which on a laptop would leave the PRD too little room beside it
diff --git a/docs/plan.md b/docs/plan.md
index b90aaf9..fe8d875 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -20,12 +20,12 @@
### Build lane
- **Where:** its own worktree per piece, on a branch from an up-to-date `main`. If the session isn't in a worktree yet (`git worktree list` shows its folder as the main checkout, not under `.claude/worktrees/`), it creates one before touching anything, and never checks out a branch in the main folder, where the measurement lane works. Claude Code's worktree option puts it under `.claude/worktrees/`; run `npm ci` there first. A worktree made with `git worktree add` gets no `.env.local`: run `npm run env:local` there, or the end-to-end tests find no interviews. Its browser check uses `app-build` in `.claude/launch.json` (port 3200), never `app` or `app-real`, which are the measurement lane's on port 3000. It never runs `measure`, `measure:fake` or `dev:real`, never edits the measurement lane's files above, and changes code in `src/server` only where its piece needs it, never what the model receives (listed above); its pull request says which `src/server` files it changed and why.
-- **Doing:** slice 15 is done on `feat/fold-search-steps` (`.claude/worktrees/slice-15-fold`), from `main` at ad27b3d (slice 14's merge), waiting for the owner's push and merge. Its form follows opencode's search tools, approved by the owner on 2026-10-20 (Design, 2026-10-20). Local checks on 2026-10-20: `verify` (454 unit tests) passed, and `test:e2e` (59, of which 2 new in `tests/e2e/chat.spec.ts`) passed in one full run on the finished code; two others failed 2 and 4 tests waiting on the fake model's answers (KI-7). The page checked at 1440px on a dev server from the worktree with the fakes: "Used 3 searches" with its faint triangle, the rows 13px, 28px high and 36px apart, cut off with an ellipsis, the slide and the turn interpolating, and the closed rows hidden and inert. It changes `src/server/ai/fake-chat-model.ts` (a new phrase, `FAKE_SEARCH_AGAIN`, so the tests get searches in a row; nothing the real model receives) and `src/features`, `src/components`, `src/app/globals.css`, so the measurement lane needn't wait for it.
+- **Doing:** slice 16 is done on `feat/fade-in-text` (`.claude/worktrees/slice-16-fade`), from `main` at 7d52f68 (slice 15's merge), pushed, its pull request's end-to-end check failed in CI on 2026-10-21 (`intro.spec.ts`, "passes the accessibility check…": `color-contrast` on a word mid-fade), fixed by a second commit that finishes every fade while axe reads; waiting for the owner's push of that and the merge. Local checks on 2026-10-21: `verify` (464 unit tests, 10 new in `tests/unit/rich-text.test.tsx` and `tests/unit/conversation.test.tsx`) and `test:e2e` (61, 2 new in `tests/e2e/chat.spec.ts`) passed in two full runs back to back. Before the accessibility check handled the fades (`tests/e2e/accessibility.ts`), it failed in three full runs of four, on a word mid-fade; after the fix, the three tests that check mid-answer passed 24 times of 24, and two more full runs passed all 61; the other failures in those runs were the fake model's timing (KI-7), which `main` showed too, 4 failed in one full run. The page checked on a dev server from the worktree with the fakes: a new answer's words each came as their own 0.18s fade, about 15ms apart, nothing in earlier answers faded, and a conversation brought back after a reload had no fading spans; the pane was hidden, so the fade itself was seen only in the end-to-end test's Chromium. It changes nothing in `src/server` or `src/lib`, so the measurement lane needn't wait for it.
- **Next:**
- 1. Slices 15 to 20 in order, each on its own branch from `main` once the one before is merged. They change only the browser's code (`src/features`, `src/components`, `src/app`'s styles and layout) and its tests, so the measurement lane needn't wait for them, except slice 19 (`src/lib/markdown.ts`, which the model never sees); its pull request says so. None changes `src/server`. Before slice 15, the owner decides its form (*Deferred*).
+ 1. Slices 17 to 20 in order, each on its own branch from `main` once the one before is merged. They change only the browser's code (`src/features`, `src/components`, `src/app`'s styles and layout) and its tests, so the measurement lane needn't wait for them, except slice 19 (`src/lib/markdown.ts`, which the model never sees); its pull request says so. None changes `src/server`.
2. Slice 8, the rate limit, once 9d is done and the owner has decided its legal basis and size (*Deferred*).
-- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once). Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token.
-- **Waiting on the owner:** pushing `feat/fold-search-steps`, its pull request and the merge; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like.
+- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' fade). On staging, a reply that arrives whole fading in as one. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token.
+- **Waiting on the owner:** pushing the fix on `feat/fade-in-text`, then the merge once CI passes; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like.
### Shared rules
- One branch per piece, from an up-to-date `main`; the owner pushes it, opens the pull request and merges it (`AGENTS.md`, Branches). After the other lane's merge, rebase an unpushed branch onto `main`, and merge `main` into a pushed one; never rewrite a pushed branch.
@@ -105,30 +105,16 @@
### 14 · Jump to latest · risk: normal · status: done (ad27b3d)
-### 15 · Fold an answer's searches into one line, as opencode does · risk: normal · status: done (on `feat/fold-search-steps`; its id on `main` goes here after the merge)
-- **Goal:** an answer's searches read like opencode's search tools: plain lines, and one line for a run of them, opening once (Design, 2026-10-20, approved from the sketch that day).
-- **Acceptance:**
- - Each search is a plain line that doesn't open: "Search" in medium weight, then the query and, in grey, whose passages came back ("Search “household composition” · Daan, Sanne"), or that none did, that it didn't work (with the reason), or that it was stopped. At 13px, as opencode's. The chat no longer shows the passages.
- - Two or more searches in a row fold into one line, "Used 3 searches", a button with `aria-expanded` that opens to their plain lines: rows of 28px, 8px apart, 4px under the folded line, no indent or side line. A text or a "Saved" line between searches starts a new run. "Saved … · See it" lines are never folded.
- - The folded line's mark is a small triangle after its text: pointing right and faint when closed, full on hover; pointing down and full when open; it turns in 0.15s. The lines slide open in about 0.35s without bounce. Under reduced motion nothing turns or slides.
- - While the answer works, the run's line is the moving line (slice 11's band): the search in progress when alone, the folded line once there are two; its count rolls up as each search starts (`RollingNumber`, slice 13). Nothing is stored apart: the form follows from the conversation, so a kept conversation shows it too.
- - CSS and the React the app has, no new dependency. Tests that read the "Searched" lines change with it.
-
-### 16 · New text fades in · risk: normal · status: todo
-- **Goal:** the assistant's text fades in instead of appearing in a jump.
-- **Acceptance:**
- - Words fade in over about 180 ms as they arrive (opencode's `markdown-word-enter`). Only the newest paragraph, list or table animates; words already shown never fade again, move or flicker.
- - Nothing is held back: the words show as soon as they arrive. On staging a reply's text arrives in one piece (whole replies: `src/server/ai/cloudflare.ts`; `src/server/AGENTS.md`, *Whole replies for chat*), so there the new text fades in as one; the fake model sends it in small pieces, so with `app-build` the words fade in by groups. Both must look right, and the check with `app-build` isn't what staging shows.
- - A finished answer, a conversation brought back after a reload, and everything under reduced motion show without animation.
- - Screen readers hear nothing new: the conversation stays out of any live region, as today.
- - Unit tests: only new words animate, text that arrives in one piece animates once, and nothing under reduced motion.
+### 15 · Fold an answer's searches into one line, as opencode does · risk: normal · status: done (7d52f68)
+
+### 16 · New text fades in · risk: normal · status: done (on `feat/fade-in-text`; its id on `main` goes here after the merge)
### 17 · Copy a section, with a "Copied" check · risk: normal · status: todo
- **Goal:** the visitor can paste one section of the PRD elsewhere.
- **Acceptance:**
- - Each saved section has a quiet "Copy" control beside its tally, without a border (Design, 2026-10-18). It copies the section as Markdown in the download's form (`src/features/prd/prd-markdown.ts`): the heading, the statements with their sources by name, and footnotes with the quotes, numbered within the section.
- - After a copy, the control reads "Copied" with a check mark for two seconds, which screen readers hear. If the browser refuses the clipboard, it reads "Couldn't copy" instead.
- - Unit tests: the copied text for a section with confirmed, unverified and visitor-decision statements, and both outcomes.
+ - Each saved section has opencode's copy button beside its tally (Design, 2026-10-21): a 24px icon button without a border, shown on a screen with hover only while the pointer is over the section or focus is inside it, always on a touch screen, with a "Copy section" label above it after the pointer rests on it. It copies the section as Markdown in the download's form (`src/features/prd/prd-markdown.ts`): the heading, the statements with their sources by name, and footnotes with the quotes, numbered within the section.
+ - After a copy, the icon is a check mark for two seconds and the button's name is "Copied", which screen readers hear. If the browser refuses the clipboard, it reads "Couldn't copy" instead.
+ - Unit tests: the copied text for a section with confirmed, unverified and visitor-decision statements, and both outcomes. End-to-end: the button hidden until the section is hovered or focused on a laptop, shown at rest on a phone, and the copied text in the clipboard.
### 18 · A designed typeface · risk: normal · status: todo
- **Goal:** the app looks the same on every device, in a typeface chosen for it.
diff --git a/src/app/globals.css b/src/app/globals.css
index 9760197..a72af60 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -122,6 +122,26 @@ body {
}
}
+/*
+ * A word of the assistant's text fading in as it arrives (opencode's
+ * `markdown-word-enter`, `packages/session-ui/src/components/markdown.css` at
+ * 50c5fce). With reduced motion the words show at once.
+ */
+@media (prefers-reduced-motion: no-preference) {
+ .word-enter {
+ animation: word-enter 180ms ease-out both;
+ }
+}
+
+@keyframes word-enter {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
/*
* A PRD section just saved, or saved again with different statements: a soft
* highlight behind it that fades to the page in about two seconds, with no
diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md
index 9a56a3c..e49c732 100644
--- a/src/features/AGENTS.md
+++ b/src/features/AGENTS.md
@@ -8,6 +8,7 @@
- **The chat's spacing:** 16px from a question to its answer, or to "Working…", and 32px between turns (`docs/decisions.md`, Design, 2026-10-20), through `data-role` on the conversation's items: a question that got no answer stays 32px from the next one. Inside an answer, 8px between steps in a row and 16px between a step and text either way (`spaceAbove` in `chat/conversation.tsx`, opencode's rule in `packages/session-ui/src/timeline/projection.ts` at 50c5fce, read 2026-10-20), and 24px before a failure notice, in the answer and above the chat's alert.
- **The moving line** (slice 11): while an answer is on its way exactly one line moves, the latest step in progress, or "Working…" at the answer's end whenever no step runs and no text arrives. Text at the answer's end counts as arriving even once its part is `done`: a reply's steps come after its text, so trailing text means the answer is finishing, and a "Working…" that flashed there before the stream closed left the chat 48px off its end, so it stopped following when the message box grew (found 2026-10-20 by `tests/e2e/layout.spec.ts`). Nothing moves once the status leaves `submitted`/`streaming` (`activityOf` in `chat/conversation.tsx`). The band is the one shared class `.shimmer` in `src/app/globals.css`, for slices 13, 15 and 20 to reuse: a gradient clipped to the text, only under `prefers-reduced-motion: no-preference` and `forced-colors: none` and where `background-clip: text` is supported, so everywhere else the line is still and grey (MDN, `background-clip`, Accessibility, read 2026-10-20 through Context7). It is decoration: the screen reader's status line in `chat/chat.tsx` is unchanged. jsdom applies no media queries, so the unit tests check which line carries `.shimmer`, and `tests/e2e/chat.spec.ts` checks the animation and the still line with `page.emulateMedia({ reducedMotion })`.
- **The search lines** (slice 15, `chat/search-step.tsx`, `chat/search-run.tsx`): opencode’s form at 50c5fce (`docs/decisions.md`, Design, 2026-10-20). Each search is a plain `
`, never a button: "Search" in medium weight, the query and what came of it in grey, 13px, one line cut off with `truncate` (a failure keeps its whole reason). The dot is `aria-hidden` and an `sr-only` "passages from" goes before the names. Searches in a row (`laidOut` in `chat/conversation.tsx`) fold into `SearchRun`: a button "Used 3 searches" with `aria-expanded`, whose count is the shared `RollingNumber` (`src/components/rolling-number.tsx`, also the PRD’s), then an inline SVG triangle, `-rotate-90` and `opacity-45` at rest, full on hover and turned down when open, through Tailwind’s `group-aria-expanded:`; its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). Interpolating the row from `0fr` was checked in Chromium only (2026-10-20); where it doesn’t interpolate, the lines appear at once. Rows are `leading-7`, 8px apart, 4px under the button. While a run’s search runs, the run’s span carries `.shimmer`, and its white parts drop their colour, since `text-foreground` would cover the band. The fake model searches three times when a message holds `FAKE_SEARCH_AGAIN`, which the end-to-end tests use.
+- **New text fades in** (slice 16, `chat/rich-text.tsx`): opencode's `markdown-word-enter` at 50c5fce, an opacity fade of 180ms (`.word-enter` in `src/app/globals.css`, only under `prefers-reduced-motion: no-preference`). Only an answer that came in after the conversation mounted fades (`shownAtFirst` in `chat/conversation.tsx`), never one it mounted with, such as a conversation brought back after a reload; this, not the chat's status, decides it, since an answer's last text can arrive in the same render that ends the answer. `RichText` keeps where each update's words begin (`starts`, in characters shown, React's "storing information from previous renders" pattern) and wraps each update's words in one span per run, so a reply that arrives in one piece, as on staging, fades as one and is still one text node for `getByText`; the fake model's words, one per 15ms, fade by groups. `Arrival` keeps from its mount whether it fades: the next update doesn't cut a fade short, and words mounted anew by a change of form (a table taking shape) don't fade again. Under reduced motion (`useReducedMotion`, `chat/use-reduced-motion.ts`, a `useSyncExternalStore` on `matchMedia`, which jsdom lacks, so there nothing asks for it) the text has no spans at all. The conversation stays out of any live region. axe reads a word mid-fade as low contrast, so `tests/e2e/accessibility.ts` finishes every fade while axe reads.
- **Stop** (slice 12): while the assistant works, the message box's Send button is Stop, one `