From 158955e08e1b6699092c75bc440a3e09f4c018dc Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Wed, 21 Oct 2026 00:22:11 +0200 Subject: [PATCH 1/3] Fade in the assistant's new text as it arrives Co-Authored-By: Claude Opus 5.5 --- docs/plan.md | 28 +--- src/app/globals.css | 20 +++ src/features/AGENTS.md | 1 + src/features/chat/conversation.tsx | 15 +- src/features/chat/rich-text.tsx | 176 +++++++++++++++++++----- src/features/chat/use-reduced-motion.ts | 32 +++++ tests/AGENTS.md | 3 +- tests/e2e/accessibility.ts | 15 ++ tests/e2e/chat.spec.ts | 34 +++++ tests/unit/conversation.test.tsx | 70 ++++++++++ tests/unit/rich-text.test.tsx | 137 +++++++++++++++++- tests/unit/workspace.test.tsx | 4 +- 12 files changed, 476 insertions(+), 59 deletions(-) create mode 100644 src/features/chat/use-reduced-motion.ts diff --git a/docs/plan.md b/docs/plan.md index b90aaf9..0774294 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), waiting for the owner's push and 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 waited for the fades (`tests/e2e/accessibility.ts`), it failed in three full runs of four, on a word mid-fade; 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 `feat/fade-in-text`, 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. @@ -105,23 +105,9 @@ ### 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. 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..4ceee78 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` waits until no fade runs. - **Stop** (slice 12): while the assistant works, the message box's Send button is Stop, one `