diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 3616d5b..0d544c8 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -21,3 +21,7 @@ In one full end-to-end run of three passes (201 tests, six workers) on this Wind ## KI-10 · Cloudflare refuses the account far below the daily limit **Found:** 2026-10-20 · **Severity:** medium Cloudflare answers 429, code 4006, "you have used up your daily free allocation of 10,000 neurons", while the day's use is far below it, as on 2026-10-20 and 2026-10-22. Staging is refused too, so visitors see the quota message (slice 8a) when the allocation isn't used up. Blocks last from minutes to about nine hours and end without a pattern found (not a rolling 24 hours, not request size). Every refused request's ray id and request id are in the measurement's not-run records (local). Fixing it is Cloudflare's, and the owner won't open a support ticket (2026-10-22), so the cause stays unknown; staging moved to a fresh Cloudflare account the same day. + +## KI-11 · A local build or dev server can keep an old `globals.css` +**Found:** 2026-10-25 · **Severity:** low +After a change to `src/app/globals.css`, a production build in a worktree served the stylesheet as it was before the change: the count's roll at its old 0.27s and clip, though the file held the new values, while other style changes made the same day came through. The dev server did the same earlier that day (a label's new position never reached the page until the server restarted), and the owner saw old behaviour in the browser twice. Deleting `.next` and building again fixed it every time. A guess: Turbopack's cache under `.next` (Next.js 16.4.2, with the Tailwind loader `@tailwindcss/turbopack` 4.3.3) not seeing the change. CI builds from scratch, so it isn't affected. Until it's understood: when a style change doesn't show, check the served CSS, then stop the server, delete `.next` and start again. diff --git a/docs/decisions.md b/docs/decisions.md index 61230ce..a0ad1e2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3,7 +3,8 @@ 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-22 · The chat is the main thing on the screen and the PRD supports it beside it (the owner, 2026-10-22: the PRD preview "takes up too much space in the web app. The chat should be the main thing and the preview should be supportive, yet not too congested or tight either", and it's "definitely way too large now"): on a laptop the PRD gets a supporting column and the chat the rest, its text kept to a comfortable reading width: on a 1440px laptop the PRD about 500px and the chat the rest (the owner, 2026-10-22), built as slice 27 after the motion of slice 26 · Replaces a chat column of steady width beside a PRD that takes the rest (2026-10-20), which on a 1440px laptop gave the PRD about 840px and the chat 520 +- 2026-10-25 · A reply is typed out as opencode shows a streaming one (the owner, 2026-10-25: a large text "pops in" and feels odd; "look at how opencode types or renders in AI text responses"; opencode's `createPacedValue` in `packages/session-ui/src/message/message-content.tsx` and its `markdown-word-enter` in `packages/session-ui/src/components/markdown.css`, at acdcdb8, read 2026-10-25): what shows of a reply grows from the top a few words every 24ms, each step ending after a word, at about 600 characters a second and faster for a long reply, so it's all shown within about 2.5 seconds of arriving (the owner's choice over a real model's slower pace and over opencode's own rules, which show anything under 512 characters at once and only smooth a reply that streams); the words each step adds fade in together over 180ms, opacity only, as opencode's do (the owner's choice over the video's rise and blur); its formatting is in place as it types, never a half-typed mark; reduced motion shows it at once · Replaces the words of 2026-10-22, flowing in over at most about half a second, rising from a slight blur, every paragraph at once, which made a long reply flash in everywhere at the same moment +- 2026-10-22 · The chat is the main thing on the screen and the PRD supports it beside it (the owner, 2026-10-22: the PRD preview "takes up too much space in the web app. The chat should be the main thing and the preview should be supportive, yet not too congested or tight either", and it's "definitely way too large now"): on a laptop the PRD gets a supporting column and the chat the rest, its text kept to a comfortable reading width: on a 1440px laptop the PRD about 500px and the chat the rest (the owner, 2026-10-22), built as slice 27 after the motion of slice 26; as built, the PRD is 36% of the page and at least 448px, where its text keeps about 45 characters a line, so on a 1024px laptop the PRD 448px and the chat 496, and the chat's text, its messages and its message box share a centred column of at most 46rem (the agent's call) · Replaces a chat column of steady width beside a PRD that takes the rest (2026-10-20), which on a 1440px laptop gave the PRD about 840px and the chat 520 - 2026-10-22 · The app's motion follows the README's demo video as closely as it can (the owner, 2026-10-22: "bring it as close as possible"; the video's project is `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository, its motion in `src/Showcase.tsx` and `src/ui/motion.ts`): one shared set of curves, the video's springs replayed in CSS with `linear()` (the owner left the build to whatever comes closest, and for one-way entrances CSS plays the same curve, so no library); what appears arrives, rising a few pixels, sharpening from a slight blur and settling; words flow in one after another even when a reply arrives whole, over at most about half a second, and words already shown never move again; searches in a row fold together with a short collapse, and a saved section's line gets a tick that draws itself, both departing from opencode's search form (2026-10-20) by the owner's choice; the pop-over opens and closes with a scale and fade, and its quote's highlight sweeps in. Reduced motion still turns all of it off, and nothing waits for an animation · Slices 25 and 26. The video looked smoother than the app: there, most things just appear, a reply that arrives whole fades in as one block in 0.18s, and five different timings are in use - 2026-10-21 · The sign that the model is thinking is opencode's (the owner, 2026-10-21: "do what opencode does"; `packages/session-ui/component-tests/session-timeline-reasoning.spec.ts` at 50c5fce, read 2026-10-21): while the model thinks, the moving line is opencode's "Thinking" row; once the reply arrives, a line "Thought" with the time in grey after it, as "Thought 7s", the title and subtitle at 13px with a 16px line height, the form of the search lines' title and query. Unlike opencode's, whose line opens to the model's reasoning, it doesn't open, since the reasoning's words never reach the browser (2026-10-20, the interface polish). Slice 20 reads the rest from opencode's source when it's built: the Thinking row's exact words and how the time is written past a minute · Settles the words deferred on 2026-10-20, "Thought for 8s" or "Took 8s" - 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 diff --git a/docs/plan.md b/docs/plan.md index 3e29291..36bcac9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,13 +20,15 @@ ### 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 26, the words, the fold and the pop-over, is done on `feat/motion-words` (`.claude/worktrees/slice-26-motion`), pushed, with `main` merged in at 747d829 (the lean chat). Its pull request's CI failed on the new replay test, which sent the app's grocery delivery suggestion: `main` had changed those words, so they matched no recording and the scripted fake answered (2026-10-22); the test now sends request B's words from the recordings. Waiting for the owner's push of the fix, a look and the merge. Local checks on 2026-10-22, on the merged code: `verify` (589 unit tests) and `test:e2e` (83) passed; three recordings of the scenario tour on a production build, read frame by frame (`src/features/AGENTS.md`, *Checking motion by recording it*): each paragraph, list or table's words flowing in, settled about 0.3s after its last starts, a list's bullets with their first words; the searches drawing together into "Used 2 searches"; the pop-over growing from its name, its highlight sweeping in, a second name reopening it by itself, Escape shrinking it back; on a phone with the page scrolled, the pop-over growing from the name's point; no long animation frame during the tour, one of 50ms at page load. Fixed on the way: a second name's pop-over stayed by the first name (on `main` too). It changes no file in `src/server` or `src/lib`, so nothing the model receives. Slice 25 is merged (aebf8a6, with its fix 5a43eb4, 2026-10-22). +- **Doing:** slice 27, the chat in front and the PRD beside it, is done on `feat/chat-in-front` (`.claude/worktrees/slice-27-chat-front`), from `main` at 4e6ff45, waiting for the owner's look, push and merge. On a laptop the PRD is 36% of the page and at least 448px (448 at 1024px, 501 at 1440, 559 at 1920) and the chat the rest (496, 859, 961); the chat's text, its messages, the suggestion and the message box share one centred column of at most 46rem; the PRD's page has 24px of padding a side; a long section heading wraps its own words with its copy button on its first line, and the button's label ends where the button ends, since centred it made the narrower PRD scroll sideways. Phones stack as before. How it's built: `src/features/AGENTS.md`, *Desktop layout* and *The chat's column*. Local checks on 2026-10-23: `verify` (589 unit tests) and `test:e2e` (85, 2 new; the PRD and layout tests then passed three more runs each); three PRD tests had pinned the old geometry (the pop-over always opening under its name, the check-in in view without scrolling) and now check the same requirement in either placement. The page was checked in the Browser pane with the replayed answers at 1024, 1440 and 1920px, and stacked at 1000 and 375. It changes no file in `src/server` or `src/lib`. +- **Also done:** on `feat/chat-in-front` too, the owner's choice (2026-10-25): one branch and one pull request for both. A search or quote check that arrives while it works no longer rises in again every 0.4s (its arrival and its moving band had mixed into one endless animation, with no band), and "Drafting…" stays beside a section whose save failed while the assistant tries again. Found by the owner on 2026-10-25 with the database down. The tour's error and retry steps (6, 8, 12 to 16, each with "Try again") then showed no other endless animation. Local checks on 2026-10-25: `verify` (590 unit tests, 1 new) and `test:e2e` (85, 2 new). It changes no file in `src/server` or `src/lib`. +- **Also on it** (the owner, 2026-10-25): "Used 3 searches" no longer loses its number while the count rolls during a running search, and a reply is typed out as opencode shows a streaming one, about 600 characters a second and all of it within about 2.5 seconds, each step's words fading in over 0.18s (`docs/decisions.md`, Design, 2026-10-25; `src/features/AGENTS.md`, *A reply is typed out*). The long recorded reply typed out on a production build with no long animation frame. A chat that follows its end keeps following while a reply types on after its answer has ended. Local checks on 2026-10-25: `verify` (589 unit tests) and `test:e2e` (88, 1 new); the layout tests passed three more runs. One PRD test now waits for the chat to reach its end instead of checking it at one moment, since the reply types on below the statements. An answer keeps one "Thinking" line through its steps, out of sight while a step runs, so it no longer blinks and restarts its band at each step, and a reply shows its first words as it arrives, taking the line's place in the same frame. A count now glides to its new number, half a line as it fades, instead of a full line's slide in a clipped box. A local build kept the old stylesheet until `.next` was deleted (KI-11). - **The README's demo** (merged, a4d5574): `docs/media/prd-assistant-demo.gif` and its source copy `prd-assistant-demo.mp4`, a motion piece about the interface made in Remotion 4.0.538 in its own project, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository; its copy, colours and timings are in that project's `src/config.ts`. `npx remotion render PrdAssistantDemo out/prd-assistant-demo.mp4 --codec=h264 --crf=16 --image-format=png`, and for the GIF `--codec=gif --every-nth-frame=2 --scale=0.5`. The GIF's click opens the MP4 through GitHub's own video hosting. - **Next:** - 1. Slice 27, the chat in front and the PRD beside it, on its own branch from `main` once `feat/motion-words` is merged; one session. The owner set its figures on 2026-10-22. - 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' arrival, on inline words' relative offsets and a list marker's colour, and the searches drawing together; the pop-over growing in on `@starting-style`, before which it opens at once, and closing the same way only where `overlay` keeps it on top (Chrome), elsewhere at once, by design; 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; what arrives, on `linear()` and `@property`, whose plain fallbacks are an ease-out and, without `@property`, a pane's rise and delay passing to what's in it). Motion on a phone and a slow machine (only headless Chromium on this one). On staging, how arrivals look at the real model's pace, 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 coming in word by word, 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/motion-words` again, with the test's fix; a look at the motion (`npm run dev:replay -- --port 3200` from the worktree, and its tour); 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. Merged and identical to `main`, `feat/motion-arrivals` (`.claude/worktrees/slice-25-motion`) and `docs/readme-video-link` (`.claude/worktrees/readme-video`) can go, with their worktrees; a session fenced into its own worktree can't remove another. + 1. Once `feat/chat-in-front` is merged: its commit id here and in slice 27. + 2. Slice 8, the rate limit, once 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; the chat's centred column and the PRD's narrower one; 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' arrival, on inline words' relative offsets and a list marker's colour, and the searches drawing together; the pop-over growing in on `@starting-style`, before which it opens at once, and closing the same way only where `overlay` keeps it on top (Chrome), elsewhere at once, by design; 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; what arrives, on `linear()` and `@property`, whose plain fallbacks are an ease-out and, without `@property`, a pane's rise and delay passing to what's in it). Motion on a phone and a slow machine (only headless Chromium on this one). On staging, how arrivals look at the real model's pace, 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 coming in word by word, 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:** a look at the new layout (`npm run dev:replay -- --port 3200` from the worktree, at 1024, 1440 and 1920px wide), then pushing `feat/chat-in-front`, 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. @@ -116,15 +118,9 @@ ### 25 · Motion from the demo video: the shared curves and arrivals · risk: normal · status: done (aebf8a6, with fix 5a43eb4) -### 26 · Motion from the demo video: words, the fold and the pop-over · risk: normal · status: done (on `feat/motion-words`; its id on `main` goes here after the merge) -- **Goal:** the reply's words, the searches' fold and the quote's pop-over move as they do in the video. -- **Acceptance:** - - Words fade in one after another, rising slightly from a slight blur, also when a reply arrives whole, as on staging, spaced so the newest paragraph, list or table settles within about half a second; words already shown never fade again, move or flicker. Once settled, the text is plain again, one text node per run, so tests and screen readers read it as before. Slice 16's tests change with it. - - When a second search joins a run, the plain lines draw together and blur out while "Used 2 searches" settles in, growing slightly; the count rolls as today, and the triangle turns on the shared curve. - - The pop-over opens from its name with a scale and fade on the pop-over spring and closes the same way; where the browser can't animate it, it opens at once. Its quote's highlight sweeps across the words just after it opens, each time it opens. - - Reduced motion: none of it. Checked as in slice 25. +### 26 · Motion from the demo video: words, the fold and the pop-over · risk: normal · status: done (50e510e, with fix 4e6ff45) -### 27 · The chat in front, the PRD beside it · risk: normal · status: todo +### 27 · The chat in front, the PRD beside it · risk: normal · status: done (on `feat/chat-in-front`; its id on `main` goes here after the merge) - **Goal:** the chat is the main thing on the screen and the PRD supports it beside it, still easy to read (`docs/decisions.md`, Design, 2026-10-22: today the PRD takes about 840px of a 1440px laptop and the chat 520). - **Acceptance:** - On a laptop the PRD is a supporting column and the chat takes the rest: on a 1440px screen the PRD about 500px and the chat the rest, about 860px (the owner, 2026-10-22). diff --git a/src/app/globals.css b/src/app/globals.css index 450126c..6b91e48 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,18 +39,16 @@ --spring-pane-duration: 0.77s; /* Its durations, with its frames. */ --duration-quick: 0.13s; /* 4: a fade that only acknowledges */ - --duration-roll: 0.27s; /* 8: a count rolling */ + /* 12: a count rolling, as long as what arrives; the video's 8 frames on + its ease-in-out looked choppy in the app (2026-10-25) */ + --duration-roll: 0.4s; /* 12: something arriving (its lines take 10, its statements 14) */ --duration-arrive: 0.4s; --duration-stagger: 0.2s; /* 6: between things arriving one by one */ /* 34: a glow that rises in 6 and fades in 26 */ --duration-highlight: 1.13s; - /* 9: a word arriving; 1.6 from one word to the next, and at most 6 from - the first word of a paragraph, list or table to its last, so it - settles within about half a second */ - --duration-word: 0.3s; - --duration-word-gap: 0.053s; - --duration-word-spread: 0.2s; + /* A typed word fading in: opencode's 180ms, not the video's (2026-10-25) */ + --duration-word: 0.18s; --duration-fold: 0.53s; /* 16: searches drawing together into one line */ --duration-sweep: 0.8s; /* 24: a highlight sweeping across its words */ } @@ -103,8 +101,10 @@ var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --radius-control: 0.25rem; - /* The workspace's page: wider than 1280px, so the PRD gets its full page. */ + /* The workspace's page: wider than 1280px. */ --container-workspace: 100rem; + /* The chat's text at most this wide, near opencode's 744px (slice 27). */ + --container-chat: 46rem; /* Tailwind's transitions, such as a button's hover, on the motion above. */ --default-transition-duration: var(--duration-quick); --default-transition-timing-function: var(--ease-in-out); @@ -125,6 +125,31 @@ body { outline-offset: 2px; } +/* + * The chat's column (slice 27): its text, the message box and what sits above + * the box at most 46rem wide and centred in the chat however wide it gets. + * The conversation's pane spans the whole chat, so its scrollbar stays at the + * chat's edge, and centres the column with its padding instead: a padding's + * percentage is of the chat's width, the pane's parent, the width the message + * box centres in. `--chat-room` is the pane's own room on the left + * (`chat/chat.tsx`). + */ +@utility chat-column { + width: 100%; + max-width: var(--container-chat); + margin-inline: auto; +} + +@utility chat-column-pane { + padding-left: calc( + var(--chat-room, 0px) + max(0px, (100% - var(--container-chat)) / 2) + ); + + & > * { + max-width: var(--container-chat); + } +} + /* * The one line that moves while the assistant works: a lighter band sweeps * across its grey text about once a second (opencode's `TextShimmer`, its @@ -201,48 +226,28 @@ body { } /* - * The assistant's words arriving one after another, as the video's do: each - * rises 4px (half the video's 8) as it sharpens from a slight blur and fades - * in, all on the ease-out. In each paragraph, list or table an update - * brings, the words follow each other 1.6 frames apart, closer when there - * are many, so its last starts at most 6 frames after its first and it - * settles within about half a second (`--word`, a word's place among them, - * and `--words`, how many: `chat/rich-text.tsx`). An inline word can't take a - * transform, so it rises by its relative offset, which moves no other word; - * the offset settles on 0, as from a length to `auto` it would only jump. A - * list item that comes with them has its bullet or number fade in with its - * first word (`.marker-arrive`). With reduced motion the words show at once. + * The assistant's words as the reply is typed out (`chat/paced-text.ts`): + * the words each step adds fade in together, as opencode's do (its + * `markdown-word-enter`, `packages/session-ui/src/components/markdown.css` at + * acdcdb8, read 2026-10-25): opacity only, on the ease-out, the owner's + * choice over the video's rise and blur (docs/decisions.md, Design, + * 2026-10-25). A list item that comes with them has its bullet or number + * fade in with its words (`.marker-arrive`). With reduced motion the words + * show at once. */ @media (prefers-reduced-motion: no-preference) { - .word-arrive, - .marker-arrive { - --word-delay: calc( - var(--word) * - min( - var(--duration-word-gap), - var(--duration-word-spread) / max(var(--words) - 1, 1) - ) - ); - } - .word-arrive { - position: relative; - top: 0; - animation: word-arrive var(--duration-word) var(--ease-out) - var(--word-delay) backwards; + animation: word-arrive var(--duration-word) var(--ease-out) backwards; } .marker-arrive::marker { - animation: marker-arrive var(--duration-word) var(--ease-out) - var(--word-delay) backwards; + animation: marker-arrive var(--duration-word) var(--ease-out) backwards; } } @keyframes word-arrive { from { - top: 0.25rem; opacity: 0; - filter: blur(2.5px); } } @@ -350,6 +355,7 @@ body { animation-duration: var(--duration-arrive); animation-timing-function: var(--ease-out), var(--ease-out-fade); animation-delay: calc(var(--order) * var(--duration-stagger)); + animation-iteration-count: 1; animation-fill-mode: backwards; } @@ -388,6 +394,33 @@ body { } } +/* + * A step that arrives while it works, a search or a quote check, carries the + * moving band too, and both are animations of the one line. `.shimmer` sets + * its animation with the shorthand and `.arrive` only some of its parts, so + * the two mixed: the line rose in again every 0.4s for as long as it worked, + * with no band (found 2026-10-25, while the database was down and each step + * waited on it). Here they run side by side, the arrival once, the band on. + */ +@media (prefers-reduced-motion: no-preference) and (forced-colors: none) { + @supports (background-clip: text) or (-webkit-background-clip: text) { + .arrive.shimmer { + animation-name: arrive, arrive-fade, shimmer; + animation-duration: var(--duration-arrive), var(--duration-arrive), 1s; + animation-timing-function: var(--ease-out), var(--ease-out-fade), linear; + animation-delay: + calc(var(--order) * var(--duration-stagger)), + calc(var(--order) * var(--duration-stagger)), 0s; + animation-iteration-count: 1, 1, infinite; + animation-fill-mode: backwards, backwards, none; + } + + .arrive-search.shimmer { + animation-timing-function: var(--ease-out), var(--ease-out), linear; + } + } +} + @keyframes arrive { from { transform: translate(var(--arrive-x), var(--arrive-y)) @@ -492,14 +525,17 @@ body { } /* - * The PRD's section count rolls to its new number: the old one leaves upwards - * as the new one comes in from below, the other way when it drops. With - * reduced motion the number changes at once. + * A count rolls to its new number, the PRD's sections and the searches in a + * folded line: the old one leaves upwards as the new one comes in from below, + * the other way when it drops, each moving half a line as it fades and blurs, + * on the ease-out, so it glides like what arrives. A full line's slide in a + * box that clipped both numbers in half, on the ease-in-out, crossed in a + * few frames and looked choppy (found 2026-10-25). With reduced motion the + * number changes at once. */ .roll { position: relative; display: inline-block; - clip-path: inset(0); } .roll-out { @@ -511,12 +547,12 @@ body { display: block; position: absolute; inset: 0; - animation: roll-out-up var(--duration-roll) var(--ease-in-out) both; + animation: roll-out-up var(--duration-roll) var(--ease-out) both; } .roll-in { display: inline-block; - animation: roll-in-up var(--duration-roll) var(--ease-in-out) both; + animation: roll-in-up var(--duration-roll) var(--ease-out) both; } .roll[data-direction="down"] .roll-out { @@ -526,29 +562,45 @@ body { .roll[data-direction="down"] .roll-in { animation-name: roll-in-down; } + + /* On a line with the moving band, whose letters are see-through and show + the band painted behind them, the rolling number, in a box of its own, + was left out and vanished for its roll (found 2026-10-25): it keeps its + own grey until it's plain text again. */ + .shimmer .roll { + color: var(--muted-foreground); + } } @keyframes roll-in-up { from { - transform: translateY(100%); + transform: translateY(0.5em); + opacity: 0; + filter: blur(2px); } } @keyframes roll-out-up { to { - transform: translateY(-100%); + transform: translateY(-0.5em); + opacity: 0; + filter: blur(2px); } } @keyframes roll-in-down { from { - transform: translateY(-100%); + transform: translateY(-0.5em); + opacity: 0; + filter: blur(2px); } } @keyframes roll-out-down { to { - transform: translateY(100%); + transform: translateY(0.5em); + opacity: 0; + filter: blur(2px); } } @@ -558,13 +610,15 @@ body { * in a quick fade, and closes when the button is pressed (`data-pressed`, * until the pointer leaves). It's held open to say "Couldn't copy". A fade, * so it runs with reduced motion too. The button's own name says the same to - * screen readers, so the label is hidden from them. + * screen readers, so the label is hidden from them. It ends where the button + * ends, not centred over it: beside a heading that wraps, the button sits at + * the PRD's right edge, and the label, laid out even while hidden, made the + * page scroll sideways (slice 27). */ .copy-tip { position: absolute; bottom: calc(100% + 0.25rem); - left: 50%; - translate: -50% 0; + right: 0; padding: 0.125rem 0.375rem; border-radius: var(--radius-control); background: var(--bubble); diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 62c8ec3..8778aed 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -3,15 +3,16 @@ ## The chat and the PRD (`workspace/`, `chat/`, `prd/`) - **One conversation, two panes:** `workspace/workspace.tsx` owns `useChat`. The PRD is built from the conversation, the latest `saveSection` result per section (`savedSections` in `src/lib/chat.ts`); nothing else changes it. "Start over" stops the answer, clears the messages, the error and the check-in, and brings the intro back. - **The check-in** sends " looks right. Please draft ." as the visitor's message, where the next is the first section in the PRD's order not drafted yet. It counts as arrived once a save of that section comes after the message it sent; until then it gives way, while the assistant works, to that section's own "Drafting…", and comes back after a failed or empty answer. -- **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 glow restarts without touching the heading's focus, a white tint that rises and fades as a statement's glow does in the README's video (`--duration-highlight`, 1.13s), which runs with reduced motion too, since a glow 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. +- **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, until a save of it in that answer comes through. A save that failed keeps it, since the assistant can try again: counting only a save in progress, it blinked off and on with each try while the database was down (found 2026-10-25). 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 glow restarts without touching the heading's focus, a white tint that rises and fades as a statement's glow does in the README's video (`--duration-highlight`, 1.13s), which runs with reduced motion too, since a glow 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 upwards and the new one comes in from below, each moving half a line as it fades and sharpens from a 2px blur, over 0.4s on the ease-out, unclipped: a full line's slide in a box clipped with `clip-path: inset(0)`, 0.27s on the ease-in-out, crossed in a few frames and cut both numbers in half, which looked choppy (found 2026-10-25). 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`). The chat is in front and the PRD a supporting column beside it (slice 27; `docs/decisions.md`, Design, 2026-10-22): the grid is `minmax(0,1fr) max(28rem,36%)`, so the PRD is 448px on a 1024px screen, where its text keeps about 45 characters a line, about 500 on a 1440px one, 559 at the page's full width, and the chat takes the rest (496, 859, 961). The PRD's page has 24px of padding a side there (`lg:px-6`), 40 when the panes stack and 20 on a phone. A saved section's heading row doesn't wrap: a long heading wraps its own words (`min-w-0`), and its copy button stays on its first line (`self-start mt-0.5`), since a wrapped row left a blank row where the button hides at rest. +- **The chat's column** (slice 27): the chat's text, the visitor's messages, the notices, the suggestion and the message box share one column, at most 46rem (`--container-chat`, near opencode's 744px), centred in the chat however wide it gets. The message box, the suggestion and the allocation notice are `chat-column` (`mx-auto`); the conversation's pane spans the whole chat, so its scrollbar stays at the chat's edge, and is `chat-column-pane`: it centres its children with its left padding, `max(0px, (100% - 46rem) / 2)`, whose percentage is of the chat's width as the box's centring is, and caps each child at 46rem. So the conversation starts where the box starts, and where the chat has room, its questions end where the box ends. No container query: MDN's pages disagree on whether `container-type: inline-size` also adds layout containment (read 2026-10-23 through Context7), and the padding needs none. 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 "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:`, turning in 0.4s on the ease-in-out as the lines slide (slice 26); 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). The box that clips them is positioned: the lines' `sr-only` labels are `absolute`, and placed against the chat's pane they escaped the clip and stretched the pane below its last line while a folded line was the answer's last (found 2026-10-22). 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. +- **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`). An answer keeps one "Thinking" line from its first step to its reply's words: while a step runs between two waits it's parked, out of sight with `visibility: hidden` and `aria-hidden`, in its place, so shown again it neither blinks nor starts its band over; removed for each step and added anew, it did both at every step (found 2026-10-25). The unit tests count only the moving lines in sight. 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:`, turning in 0.4s on the ease-in-out as the lines slide (slice 26); 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). The box that clips them is positioned: the lines' `sr-only` labels are `absolute`, and placed against the chat's pane they escaped the clip and stretched the pane below its last line while a folded line was the answer's last (found 2026-10-22). 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. Its count, while it rolls, keeps its own grey (`.shimmer .roll`): the band paints the line through its see-through letters, and left out the rolling number, in a box of its own, so the count vanished for each roll (found 2026-10-25). 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. - **Searches drawing together** (slice 26, `chat/search-run.tsx`): when a second search joins plain lines that were showing, in an answer on its way, a copy of those lines (`aria-hidden`) stays in the run's place, in its flow, so nothing around it moves, and in a layout effect, before the paint, the run measures it: the copy's lines move up into the first one's place as they blur out and fade (`.fold-leaves`, `--fold-y`) while their box closes to the folded line's height (`.fold-closes`, `--fold-from` to `--fold-to`), in 0.53s on the ease-in-out; from halfway "Used 2 searches", over them until the copy is gone (`absolute`), settles in, growing from 96% from its left (`.fold-settles`). The box's `animationend` removes the copy. Which lines were showing comes from `AssistantMessage`, which keeps how many parts showed before the latest came (`counted`): two searches that arrive together, a run an answer was brought back with, and reduced motion or a browser without `getAnimations` (`useMotion`) fold at once, as before. Once a run has formed, later searches only roll its count. - **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 arrives word by word** (slice 26, `chat/rich-text.tsx`, replacing slice 16's fade of each update as one): the video's words, each rising 4px from a 2.5px blur as it fades in, 0.3s on the ease-out (`.word-arrive` in `src/app/globals.css`, only under `prefers-reduced-motion: no-preference`). Only an answer that came in after the conversation mounted moves (`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); the newest update's words are each a span, `--word` its place among them in its paragraph, list or table and `--words` how many there, which CSS turns into a delay at most 53ms apart (the video's 1.6 frames) and closer when there are many, so each block's last word starts within 0.2s and a reply that arrives whole, as on staging, settles within about half a second; the spaces stay plain between them. Each block spaces its own words, all from 0 (`beginBlock`): spaced across the whole reply, the end of a long one, which is what the chat shows, came last, after 0.2s of nothing (found 2026-10-22 in a recording). 0.2s holds about twelve frames, so a block makes at most 16 spans (`MOST_PIECES`), a few words each past that, from places that are multiples of how many; it looks the same and keeps a long reply's render light. A word cut by an update arrives in two parts, and inline code as one word. A list item that comes with the update has its bullet or number fade in with its first word (`ListItem`, `.marker-arrive`, an animation of `::marker`'s colour: Chrome 86, Firefox 80, Safari 14.1), so it doesn't show before its words. `Arrival`, the words of one update in one run, keeps from its mount whether they arrive and their places: the next update doesn't cut an arrival short or move it, and words mounted anew by a change of form (a table taking shape) don't arrive again; one component a run, not a word, so a long reply's render stays light. An inline word can't take a transform, so it rises by `position: relative` and `top`, which moves nothing around it and keeps the line breaks the plain text has; `top` settles on 0, since from a length to `auto` it only jumps. Once a word's animation ends, the text looks in the next frame whether `getAnimations` lists any word still on its way, once a frame however many ended in it (looking at each of an 840-word reply's ends took a 248ms frame: 2026-10-22); when none is, everything shown is plain again (`settled`), one text node per run, as `getByText` and screen readers read it, and what comes after arrives as before. Where motion is reduced or the browser lists no animations (`useMotion`, `chat/use-reduced-motion.ts`: `useReducedMotion`, a `useSyncExternalStore` on `matchMedia`, and `Element.prototype.getAnimations`, Chrome 84, Firefox 75, Safari 13.1), the text has no spans at all, so jsdom, which has neither, reads whole text; the unit tests of what moves define `getAnimations` themselves. The conversation stays out of any live region. axe reads a word mid-arrival as low contrast, so `tests/e2e/accessibility.ts` finishes every animation that ends while axe reads. +- **A reply is typed out** (2026-10-25, `chat/paced-text.ts`, `chat/rich-text.tsx`, replacing slice 26's words, which flowed in over half a second, every paragraph at once): opencode's way of showing a streaming reply (`docs/decisions.md`, Design, 2026-10-25). `usePacedLength` counts how many of the text's shown characters show: from none when an arriving text mounts, a step every 24ms, about 15 characters (600 a second) or more for a long text, so it's all shown within about 2.5 seconds of growing, each step ending just after a word that ends within 8 characters (opencode's snap). It paces the text as shown, after `parseMarkdown`, so formatting is in place as it types and a half-typed mark never appears; `RichText` lays out only the characters that show (`Words.shown`, `unreached`): a block, a list's item or a table's row that isn't reached yet isn't rendered, so a table grows row by row, its header first, its cells filling in. The words each step adds fade in together, one span a run (`Arrival`, `.word-arrive`: opacity over 0.18s on the ease-out, opencode's `markdown-word-enter`), and a list item that comes with a step has its bullet or number fade in with it (`.marker-arrive`). Only an answer that came in after the conversation mounted is typed out (`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, and the typing can go on after the answer has ended. `RichText` keeps where each step's words begin (`starts`, in characters shown, React's "storing information from previous renders" pattern), so a step's fade isn't cut short by the next, and words shown before never fade again. Once a word's animation ends, the text looks in the next frame whether `getAnimations` lists any word still on its way, once a frame however many ended in it; when none is, everything shown is plain again (`settled`), one text node per run, as `getByText` and screen readers read it. Where motion is reduced or the browser lists no animations (`useMotion`, `chat/use-reduced-motion.ts`), the text shows whole at once, with no spans, so jsdom reads whole text; the unit tests of what moves define `getAnimations` themselves and use fake timers, moving them a frame at a time, since each step is set once the last has rendered. On a production build the long recorded reply typed out with no long animation frame (2026-10-25). The conversation stays out of any live region. axe reads a word mid-fade as low contrast, so `tests/e2e/accessibility.ts` finishes every animation that ends 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 ` + + {padded(header).map((cell, column) => + cell.length > 0 ? ( + + ) : ( + + + ); + const typedRows: ReactNode[] = []; + for (const [number, row] of rows.entries()) { + if (unreached(words)) break; + typedRows.push( + + {padded(row).map((cell, column) => ( + + ))} + , + ); + } return (
` 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 ``, since it - * names nothing. + * names nothing. While it's typed out, its rows come one by one, their cells + * filling in. */ function table({ header, rows }: Table, words: Words | undefined) { const columns = Math.max( @@ -302,40 +246,44 @@ function table({ header, rows }: Table, words: Words | undefined) { ); const padded = (cells: Cell[]) => Array.from({ length: columns }, (_, column) => cells[column] ?? []); + // Laid out in reading order, the header first, since laying out counts + // the characters typed so far. + const head = header && ( +
+ {runs(cell, words)} + + ), + )} +
+ {runs(cell, words)} +
- {header && ( - - - {padded(header).map((cell, column) => - cell.length > 0 ? ( - - ) : ( - - - )} - {rows.length > 0 && ( - - {rows.map((row, number) => ( - - {padded(row).map((cell, column) => ( - - ))} - - ))} - - )} + {head} + {typedRows.length > 0 && {typedRows}}
- {runs(cell, words)} - - ), - )} -
- {runs(cell, words)} -
); } @@ -392,6 +340,7 @@ function runs(line: Inline[], words: Words | undefined) { case "text": return {wordsOf(run.text, words)}; case "bold": + if (unreached(words)) return null; return ( {wordsOf(run.text, words)} @@ -404,22 +353,23 @@ function runs(line: Inline[], words: Words | undefined) { ); if (!words) return {code}; + if (unreached(words)) return null; const start = words.at; words.at += run.text.length; if (start < words.settled) return {code}; - // Inline code arrives as one word, with the update it began in. - const newest = - updateAt(words.starts, start) === words.starts.length - 1; - const first = words.index; - if (newest) words.index += 1; + // Inline code arrives whole, with the step it began in. return ( - + {code} ); } case "break": + if (unreached(words)) return null; if (words) words.at += 1; return
; } @@ -427,16 +377,17 @@ function runs(line: Inline[], words: Words | undefined) { } /** - * The text as it is, or, while its words arrive, what's plain again, then - * each update's words, the newest update's one after another. + * The text as it is, or, while it's typed out, as much of it as shows: what's + * plain again, then each step's words, the newest step's arriving. */ function wordsOf(text: string, words: Words | undefined): ReactNode { if (!words) return text; const { starts, settled } = words; const from = words.at; - const to = from + text.length; - words.at = to; - if (to <= settled) return text; + const to = Math.min(from + text.length, words.shown); + words.at = from + text.length; + if (to <= from) return null; + if (to <= settled) return text.slice(0, to - from); const pieces: ReactNode[] = []; if (settled > from) pieces.push( @@ -450,92 +401,35 @@ function wordsOf(text: string, words: Words | undefined): ReactNode { const begin = Math.max(starts[update]!, from, settled) - from; const end = Math.min(starts[update + 1] ?? to, to) - from; if (end <= begin) continue; - const piece = text.slice(begin, end); - const newest = update === starts.length - 1; - const first = words.index; - if (newest) words.index += tokens(piece).filter((t) => !isSpace(t)).length; pieces.push( - - {piece} + + {text.slice(begin, end)} , ); } return pieces; } -/** The update whose words hold the character at the offset. */ +/** The step whose words hold the character at the offset. */ function updateAt(starts: number[], offset: number): number { return starts.findLastIndex((start) => start <= offset); } /** - * At most this many pieces of a paragraph, list or table arrive one after - * another: their 0.2s holds about twelve frames, so more would start in the - * same frames anyway. Past it, a few words arrive together, which keeps a - * long reply's render light. - */ -const MOST_PIECES = 16; - -/** - * The words of one update in one run, or a piece of inline code, which - * arrive when they're the newest update's: each word a span in its place - * among them (`--word`, from `first`), of how many (`--words`), the spaces - * between them plain; in a long paragraph, list or table a few words share a - * span, which begins at a place that's a multiple of how many (`together`). - * It keeps what it was given when it mounted, so the next update doesn't cut - * an arrival short or move it, and words shown before never arrive again, - * even when a change in the text's form, such as a table taking shape, mounts - * them anew. + * The words of one step in one run, or a piece of inline code, which fade in + * together when they're the newest step's, as opencode's do. It keeps what + * it was given when it mounted, so the next step doesn't cut a fade short, + * and words shown before never arrive again, even when a change in the + * text's form, such as a table taking shape, mounts them anew. */ function Arrival({ enter, - first, - of, children, }: { enter: boolean; - first: number; - of: number; children: string | ReactElement; }) { - const [place] = useState(enter ? { first, of } : undefined); - if (!place) return children; - const piece = (index: number, content: ReactNode, key?: number) => ( - - {content} - - ); - if (typeof children !== "string") return piece(place.first, children); - const together = Math.max(Math.ceil(place.of / MOST_PIECES), 1); - const pieces: ReactNode[] = []; - // The span being filled: its first word's place, where it starts in the - // text, and its words so far; and the spaces after it, until the next word - // says whether they're inside it. - let open: { index: number; at: number; text: string } | undefined; - let space = ""; - let index = place.first; - let at = 0; - for (const token of tokens(children)) { - if (isSpace(token)) { - space += token; - } else if (open && index % together !== 0) { - open.text += space + token; - space = ""; - index += 1; - } else { - if (open) pieces.push(piece(open.index, open.text, open.at)); - if (space) pieces.push(space); - open = { index, at, text: token }; - space = ""; - index += 1; - } - at += token.length; - } - if (open) pieces.push(piece(open.index, open.text, open.at)); - if (space) pieces.push(space); - return pieces; + const [entering] = useState(enter); + if (!entering) return children; + return {children}; } diff --git a/src/features/chat/thought-line.tsx b/src/features/chat/thought-line.tsx index 54a9d53..c1729cf 100644 --- a/src/features/chat/thought-line.tsx +++ b/src/features/chat/thought-line.tsx @@ -7,10 +7,22 @@ import { thoughtTime, type ThoughtPart } from "./thoughts"; // lines. Unlike opencode's, the line doesn't open: the reasoning's words // never reach the browser. -/** "Thinking", with the moving band (`.shimmer` in `src/app/globals.css`). */ -export function ThinkingLine({ className }: { className?: string }) { +/** + * "Thinking", with the moving band (`.shimmer` in `src/app/globals.css`). + * `parked`, it keeps its place and its band out of sight, and out of the + * accessibility tree, while a step runs between two waits. + */ +export function ThinkingLine({ + parked = false, + className, +}: { + parked?: boolean; + className?: string; +}) { return (

+