diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md
index 14b318c..db125f8 100644
--- a/KNOWN-ISSUES.md
+++ b/KNOWN-ISSUES.md
@@ -15,6 +15,7 @@ What happens and how to reproduce it. What's known about the cause, with guesses
In one run during slice 11, and not on the run before or after: `tests/e2e/prd.spec.ts`, "reads Drafting while it works, and comes back with a line beside it when the answer fails" (since slice 13, "says Drafting beside the next section while it works, …"), waited past its 5 seconds with the fake model's answer stuck after the saved section. It passed five times in a row on its own and in the full suites afterwards. A guess: it waits on the fake model's timing, which six workers on a busy machine stretch. Fixing it means finding what it waits on and waiting for that condition instead; if it fails again, note the run here. (The unit test that failed with it was a test reading storage too early, fixed with slice 12; `tests/AGENTS.md`, *Writing tests*.)
Again during slice 14, in two full runs of five: 2 and then 3 tests (`prd.spec.ts`'s pop-over tests, "Start over asks once…", "follows the newest message…", `quota.spec.ts`'s "the chat and the PRD so far stay readable…"), each in `draftFirstSection` or its like, waiting past 5 seconds for the first draft's answer to end while the fake model was still streaming its reply. The same tests passed twice each on their own, and the three full runs after them passed, one of them without slice 14's change to the chat. So the wait is the fake model's answer under six workers, not what the page does.
Again during slice 15 (2026-10-20), in full runs back to back on the finished code: one failed 2 tests (not recorded), one passed all 59, one failed 4: `layout.spec.ts`'s three Jump to latest tests and `prd.spec.ts`'s "follows the newest message…", each waiting past 5 seconds for a first draft's answer while the fake model was still at a search or the check. One of them, "offers Jump to latest once opened searches push…", now drafts after three searches (`FAKE_SEARCH_AGAIN`), a longer answer, so it has less room in that wait.
+Again during slice 20 (2026-10-21), in the first full run on the finished code: 6 tests (`prd.spec.ts`'s Drafting, light-up, See it and copy tests), each waiting past 5 seconds for the first draft's answer while it showed "Checking the quotes in Introduction / Overview…". All of `prd.spec.ts` passed on its own right after, and the next full run passed all 67.
## KI-8 · `next dev` adds its own block to `AGENTS.md`
**Found:** 2026-10-20 · **Severity:** low
diff --git a/docs/plan.md b/docs/plan.md
index 10c6592..0d6e09e 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -20,12 +20,11 @@
### 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 19 is done on `feat/markdown-tables` (`.claude/worktrees/slice-19-tables`), from `main` at 32315ed (slice 18's merge), waiting for the owner's push and merge. Local checks on 2026-10-21: `verify` (468 unit tests; the card tests in `tests/unit/markdown.test.ts` and `tests/unit/rich-text.test.tsx` gave way to table tests) and `test:e2e` (66, 2 new in `tests/e2e/chat.spec.ts`) passed in two full runs; a first full run failed 10, the 2 new among them, all passing again with two workers, and the 2 new passed 12 times of 12 in parallel (KI-7). The page checked on a dev server from the worktree with the fakes, at 1440px and 375px: the header in medium weight over the stronger line, 12px padding, top-aligned; a six-column table scrolling inside the message, a stop for Shift+Tab with the white focus ring, and a two-column one fitting without a stop; the page never scrolls sideways. Unlike opencode's, each cell is at least 144px wide, since the six columns squeezed into the chat showed a word per line (`docs/decisions.md`, Design, 2026-10-20). It changes `src/lib/markdown.ts`, which only the browser uses, and nothing in `src/server`, so the measurement lane needn't wait for it.
+- **Doing:** slice 20 is done on `feat/thinking-line` (`.claude/worktrees/slice-20-thinking`), from `main` at b446e43 (slice 19's merge), waiting for the owner's push and merge. Local checks on 2026-10-21: `verify` (488 unit tests, 2 files new or changed for the thinking lines) passed, its build on a second try after a network error reaching Google Fonts; `test:e2e` (67, 1 new in `tests/e2e/chat.spec.ts`; slice 11's and slice 15's tests now read "Thinking" and count the thought lines) passed in the second full run, the first failing 6 waiting on the fake model's answer (KI-7). The page checked on a dev server from the worktree with the fakes, at 1440px and 375px: "Thinking" at 13px, medium, on a 16px line with the moving band; "Thought 2s" with the word in white and the time in grey; inside "Used 3 searches" the thoughts and searches alternate, rows 28px high and 36px apart; the times kept through a reload; no sideways scroll on a phone. It changes only `src/features` and its tests, nothing in `src/server` or `src/lib`; the times go back to the server inside the answers, and a unit test shows the model's input is the same with and without them, so the measurement lane needn't wait for it.
- **Next:**
- 1. Slice 20 on its own branch from `main` once slice 19 is merged, in a fresh session; its form is decided (`docs/decisions.md`, Design, 2026-10-21). It changes only the browser's code and its tests, nothing in `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; the words' fade; the copy button, its label and the clipboard; Inter and its resized fallback, which needs Arial installed, so Linux and Android show the system's font until Inter arrives; a table's cells keeping their 144px and its box's sideways scroll). On staging, a real reply's table. On staging, a reply that arrives whole fading in as one, and a section's copy over HTTPS pasted into another app. 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/markdown-tables`, 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.
+ 1. Slice 8, the rate limit, once 9d is done and the owner has decided its legal basis and size (*Deferred*). No other piece is planned for this lane.
+- **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; the copy button, its label and the clipboard; Inter and its resized fallback, which needs Arial installed, so Linux and Android show the system's font until Inter arrives; a table's cells keeping their 144px and its box's sideways scroll; the Thinking line's band). On staging, a real reply's table, and the Thinking line through the real model's waits with the times it shows (the fake model's are about 0s). On staging, a reply that arrives whole fading in as one, and a section's copy over HTTPS pasted into another app. 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/thinking-line`, 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.
### 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.
@@ -113,13 +112,6 @@
### 18 · A designed typeface · risk: normal · status: done (32315ed)
-### 19 · Tables like opencode's · risk: normal · status: done (on `feat/markdown-tables`; its id on `main` goes here after the merge)
+### 19 · Tables like opencode's · risk: normal · status: done (71e6e93)
-### 20 · Show when the model is thinking · risk: normal · status: todo
-- **Goal:** the visitor sees when the model is thinking and for how long, never what it thinks.
-- **Acceptance:**
- - Its form is opencode's (the owner, 2026-10-21; `docs/decisions.md`, Design): "Thinking" while the model thinks, then "Thought" with the time in grey, as "Thought 8s", a line that doesn't open.
- - The chat model gives whole replies (`src/server/ai/cloudflare.ts`; `src/server/AGENTS.md`, *Whole replies for chat*), so the server learns of the model's reasoning only with the reply, together with its text or its step, and can't say live when the reasoning starts or how long it took on its own. What the browser can see is each wait for a reply, and gpt-oss reasons before it replies (Harmony's analysis channel), so that wait is the sign: whenever an answer is on its way and no step is in progress and no text is arriving, slice 11's moving line reads as opencode's Thinking row instead of "Working…", and slice 11's tests change with it. The first wait starts with the request; a later one starts when a step finishes.
- - Once the reply arrives, the line "Thought 8s" stays in the line's place in the answer: the wait, measured in the browser and kept with the conversation as slice 15 keeps the answer's time, so it outlasts a reload; an answer kept from before this piece shows none. With slice 15, these lines fold with the steps. For the answer's last reply the wait also covers the model writing its text; the owner chose opencode's word all the same.
- - The reasoning's words never reach the browser, as today (`sendReasoning: false` in `src/server/chat.ts`; `src/server/AGENTS.md`, *Reasoning*): 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, *Reasoning*, read 2026-10-20 through Context7).
- - Nothing in `src/server` changes: the browser measures and keeps the waits itself, on the assistant's messages (a visitor's message may hold only text: `readChatRequest`). The model receives exactly what it receives today: a unit test runs the chat with the fake model on a conversation with and without the kept waits and shows the model's input is the same (the AI SDK's `convertToModelMessages` leaves out `data-` parts and a message's metadata, and `readChatRequest` accepts both; ai 7.0.143, read 2026-10-20). If that can't hold, it isn't built, and the owner hears why. `STORED_FORMAT` is raised if the kept form changes.
+### 20 · Show when the model is thinking · risk: normal · status: done (on `feat/thinking-line`; its id on `main` goes here after the merge)
diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md
index 3315bc8..ad1b589 100644
--- a/src/features/AGENTS.md
+++ b/src/features/AGENTS.md
@@ -6,9 +6,10 @@
- **What the PRD shows changing** (slice 13): "Drafting…" sits beside the heading of each section being drafted while the assistant works (`sectionsBeingDrafted` in `workspace/workspace.tsx`): the check-in's section from its message until it arrives, and any section whose save has started in the answer on its way. It's a sibling of the `
`, not inside it, so the heading's and the region's names don't change, and it carries `.shimmer`. A section saved, or saved again with different statements (compared as JSON), lights up: a decorative `span.just-changed` behind it, remounted with a new `key` on each change so the fade restarts without touching the heading's focus, a white tint fading to transparent in 2s, which runs with reduced motion too, since a fade doesn't move. The sections the PRD mounts with, such as those brought back after a reload, and a save that changes nothing, don't count (`useChanges` in `prd/prd-document.tsx`, React's "storing information from previous renders" pattern). The title line's count rolls (`RollingNumber`, `.roll` in `src/app/globals.css`): plain text at rest, so a text query still finds the whole line; during a change the old number leaves and the new one comes in, clipped with `clip-path: inset(0)`, since `overflow: hidden` would move an inline block's baseline (MDN, `clip-path`, read 2026-10-20 through Context7). With reduced motion the old number is `display: none` and no `animationend` comes, so the rolling markup stays until the next change. jsdom has no `AnimationEvent`, so React listens for a vendor-prefixed event there; `tests/unit/animation-event.ts` defines one before React loads.
- **Desktop layout:** the body takes the viewport height only on pages that hold `[data-workspace]` (`lg:has-[[data-workspace]]:h-dvh` in `src/app/layout.tsx`, Tailwind's `has-*` variant), so the two panes scroll on their own and the transcripts page scrolls as before. On those pages the page is at most 1600px wide (`max-w-workspace`, from `--container-workspace` in `src/app/globals.css`), and the chat's column is `clamp(25rem, 38%, 32.5rem)`: 400px on a 1024px screen, 520px from about 1416px, with the PRD taking the rest (`docs/decisions.md`, Design, 2026-10-20). The header, the main area's padding and the footer take 114px of the height. The header is 45px on every page, not only the workspace, so it doesn't change height when the intro gives way to the chat; the focused skip link fits inside it. `tests/e2e/layout.spec.ts` measures all of it.
- **The typeface** (slice 18): Inter, through `next/font/google` in `src/app/layout.tsx` (Next.js 16, `next/font`, read 2026-10-21 through Context7): its files are downloaded at build time and served from `/_next/static/media/`, so the visitor's browser never asks Google, and the build needs Google Fonts to be reachable. Its licence is the SIL Open Font License 1.1 (https://github.com/rsms/inter, `LICENSE.txt`, read 2026-10-21), which allows bundling it with software. `variable: "--font-inter"` on `` puts it first in `--font-sans` (`src/app/globals.css`, in `@theme inline`, so the variable resolves where it's used: Tailwind, *Theme variables*, read 2026-10-21 through Context7), with the system fonts behind it in case it fails. Inter is variable, so 400, 500 and 600 come from one file. Against a jump, Next adds "Inter Fallback", `local(Arial)` resized to Inter's metrics, shown until Inter arrives. On Windows Chromium, with Inter held back, the transcripts page grew 20px of 7,216 when it arrived, against 72px with plain Arial, and the layout-shift score stayed near 0 in both, as the top of the page barely moves; so `tests/e2e/pages.spec.ts` checks the fallback's form, not a measured jump. Where Arial isn't installed (Linux, Android), the fallback is the system's font, unresized.
-- **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.
+- **The chat's spacing:** 16px from a question to its answer, or to "Thinking", 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 "Thinking" (slice 20, below) at the answer's end whenever no step runs and no reply arrives. Text at the answer's end counts as arriving even once its part is `done`, and so does a thought's time, which comes just before its reply: a reply's steps come after its text, so trailing text means the answer is finishing, and a "Working…", as the line read then, 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. A run takes in the thought lines (below) in its unbroken stretch of searches and thoughts, before, between and after the searches, as opencode's `groupContent` does; a stretch with one search stays plain lines, and the count counts searches only.
+- **How long the model thought** (slice 20, `chat/thoughts.ts`, `chat/thought-line.tsx`): opencode's form at 50c5fce (`docs/decisions.md`, Design, 2026-10-21): "Thinking" while it thinks, 13px medium on a 16px line with `.shimmer`, then "Thought" in medium weight and white with the time in grey and tabular figures, as "Thought 8s", and from a minute "1m 5s", whole seconds rounded (opencode's `AssistantReasoningContent` in `packages/session-ui/src/message/message-content.tsx` and its words in `packages/ui/src/i18n/en.ts`, read 2026-10-21). The line doesn't open. The server can't time the reasoning, since the chat model's replies arrive whole, so the browser times each wait for a reply: `TimedTransport` in `workspace/workspace.tsx` pipes each answer's stream through `timeThoughts`, which starts the first wait at the request and each later one when the last step running finishes (a tool output), and puts a `data-thought` chunk with `{ ms }` just before the chunk that begins the next reply (text or a tool's input). The AI SDK keeps it as a part of the answer in that place (any `data-*` chunk without `transient`, ai 7.0.143, `processUIMessageStream`), so it's stored and restored with the conversation; a wait the answer ends in, stopped or failed, gets none. The parts go back to the server with the conversation, where `safeValidateUIMessages` accepts any `data-*` part and `convertToModelMessages` drops it unless given `convertDataPart`, so the model's input doesn't change (`tests/unit/chat.test.ts` compares it with and without the times). `finishedOnly` counts a thought as nothing finished. An answer kept before slice 20 has none and shows none, so `STORED_FORMAT` stayed 2: the new part only adds to what fits.
- **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.
- **Tables** (slice 19, `chat/rich-text.tsx`; the parser in `src/lib/markdown.ts`, which only the browser uses): opencode's at 50c5fce (`docs/decisions.md`, Design, 2026-10-20): a real `
`, its header's named cells `
` in medium weight over `border-border-strong`, rows over `border-border`, both `0.5px` (Chromium draws 1px on a screen of 1x), cells `p-3 align-top`. Each cell is `min-w-36`, which Chromium honours on table cells, so a wide table scrolls rather than squeezing its columns to a word per line. Every row is padded to the widest row or the header with empty `
`s, so the hairlines run the full width; a header's empty name is a `
` too, as it names nothing, and a row or header of nothing but empty cells is dropped by the parser. The table sits in `SideScroll`, an `overflow-x-auto` box that a `ResizeObserver` measures, on itself and on the table: while the table is wider, the box is `role="region"`, named "Table, scrolls sideways", with `tabIndex={0}`, so the keyboard reaches it (axe's `scrollable-region-focusable`, which checks only a box that overflows); a table that fits adds no tab stop. A hidden Browser pane delivers the observer's first measure only when it next draws. The cells' runs are built in `RichText`'s render, not inside `SideScroll`, so the fade's character count (`words.at`) keeps the rendered order. The fake model writes no table, so `tests/e2e/chat.spec.ts` opens a kept conversation that holds two.
- **Stop** (slice 12): while the assistant works, the message box's Send button is Stop, one `