From ea47685fafabf2828037f1d09bb3ec6d3efdeeca Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Thu, 22 Oct 2026 23:42:40 +0200 Subject: [PATCH 1/6] Record slice 26's merge [skip ci] Co-Authored-By: Claude Opus 5.5 --- docs/plan.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 3e29291..e8e5dbd 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,13 +20,13 @@ ### 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, starts on `feat/chat-in-front` (`.claude/worktrees/slice-27-chat-front`, its packages installed and `.env.local` written), from `main` at 4e6ff45. Slice 26 is merged (50e510e, with the test's fix 4e6ff45, 2026-10-22): the words, the fold, the pop-over and how they were checked are in `src/features/AGENTS.md`, *Motion*; its recordings showed no long animation frame during the tour, one of 50ms at page load. A replay test sends words read from the recordings, never the app's (`tests/AGENTS.md`). - **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. + 1. Slice 27, on `feat/chat-in-front`; 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. +- **Waiting on the owner:** 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,13 +116,7 @@ ### 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 - **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). From 3368c2897f40c50db050a0d41a1caf64e923b1f1 Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Fri, 23 Oct 2026 20:29:55 +0200 Subject: [PATCH 2/6] Put the chat in front and the PRD in a supporting column beside it Co-Authored-By: Claude Opus 5.5 --- docs/decisions.md | 2 +- docs/plan.md | 12 +-- src/app/globals.css | 37 ++++++- src/features/AGENTS.md | 7 +- src/features/chat/chat.tsx | 15 +-- src/features/prd/copy-section.tsx | 2 +- src/features/prd/prd-document.tsx | 12 ++- src/features/workspace/workspace.tsx | 8 +- tests/e2e/layout.spec.ts | 148 +++++++++++++++++++++++---- tests/e2e/prd.spec.ts | 35 +++++-- 10 files changed, 224 insertions(+), 54 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 61230ce..464d24f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3,7 +3,7 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · consequence`. When a decision changes, replace its line; Git keeps the old one. Agents read the section their task touches. ## Design -- 2026-10-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-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 e8e5dbd..8beec45 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,13 +20,13 @@ ### 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 27, the chat in front and the PRD beside it, starts on `feat/chat-in-front` (`.claude/worktrees/slice-27-chat-front`, its packages installed and `.env.local` written), from `main` at 4e6ff45. Slice 26 is merged (50e510e, with the test's fix 4e6ff45, 2026-10-22): the words, the fold, the pop-over and how they were checked are in `src/features/AGENTS.md`, *Motion*; its recordings showed no long animation frame during the tour, one of 50ms at page load. A replay test sends words read from the recordings, never the app's (`tests/AGENTS.md`). +- **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`. - **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, on `feat/chat-in-front`; 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:** whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. + 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. @@ -118,7 +118,7 @@ ### 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..181d0bf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -103,8 +103,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 +127,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 @@ -558,13 +585,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..1528c8c 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -4,7 +4,8 @@ - **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. +- **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 })`. @@ -26,7 +27,7 @@ - **Only what comes after the page showed:** the conversation's messages by `shownAtFirst` (`chat/conversation.tsx`), whose `arriving` reaches every line of an answer; the PRD's statements by `useChanges`; the rest by `useArrival` (`src/components/arrival.ts`): it arrives unless it was showing when its component mounted and has stayed since, and under a new name (the check-in moving to the next section) it arrives again. React's "storing information from previous renders" pattern, since `react-hooks/set-state-in-effect` and `react-hooks/refs` rule out an effect or a ref that marks the page as shown. A saved section's line is keyed by what it says (checking, stopped, failed, saved), so each is a new line that arrives; a search's line stays one line as it finishes. Not arriving: "Thinking" and "Drafting…", status lines that just appear, as the video's Thinking does; the folded line, which settles in only as plain lines draw into it (above); the intro. - **The tick** (`Tick` in `chat/section-step.tsx`, `.tick-draws`): the video's check at half size. On a line that arrives its circle draws from the top in 12 frames, the tick from frame 8 in 10, and one ring pulses out 7px from frame 10 in 18 (`::after`); dash lengths 41 and 9, the circle's and the tick's. Brought back after a reload, it's simply there. - **The chat follows its end as laid out** (`followEnd` in `chat/chat.tsx`): its children's `offsetTop + offsetHeight`, which leave transforms out. Following `scrollHeight`, which counts a rising line's pixels while it rises, the chat went past its end, then the whole conversation sank back as the newest line settled, instead of the line rising into place (found 2026-10-22 in a recording; `tests/e2e/layout.spec.ts`). It showed only when an arrival was the chat's last line, as a failure notice is. Jump to latest still scrolls to `scrollHeight`, which the browser clamps. -- **Room on the left:** the chat's scroller has 8px of padding on its left, given back by an 8px negative margin (`lg:-ml-2 lg:pl-2`), so the tick's ring and a search sliding in aren't cut off where it scrolls (a box that scrolls clips sideways too); nothing moves. +- **Room on the left:** the chat's scroller has 8px more padding on its left than its column needs (`--chat-room`, added to `chat-column-pane`'s padding), given back by an 8px negative margin (`lg:-ml-2 lg:[--chat-room:0.5rem]`), so the tick's ring and a search sliding in aren't cut off where it scrolls (a box that scrolls clips sideways too); nothing moves. - **Tests:** jsdom runs no animation, so the unit tests check which elements carry the classes; the end-to-end tests check `animation-name` under both motion settings, that a settled element has no opacity, transform or filter left, and that a reload brings nothing in. What lasts under a second, or is gone once it has run (the words' spans, the lines that fold, the pop-over's transitions), they record as it comes, with `recordArrivals` (`tests/e2e/motion.ts`, a `MutationObserver` from an init script) or the transition and animation events. `tests/e2e/accessibility.ts` and the layout tests' `childGaps` finish every animation that ends before they read, and a test that measures the pop-over finishes its opening first. - **Checking motion by recording it** (slice 25, 2026-10-22): the scenario tour on a production build (`NEXT_PUBLIC_CHAT_REPLAY=1 npm run build`, then `AI_CHAT_PROVIDER=replay REPLAY_SPEED=4 npm run start -- --port 3201`, the end-to-end replay server's settings at a watchable pace), driven by a Playwright script: `page.screencast.start({ onFrame })` hands over each frame the browser presents with its time; an init script's `MutationObserver` logs each arrival as it mounts, with its box then and 1.5s later; a `long-animation-frame` `PerformanceObserver` logs long frames. Frames around each arrival, cropped and laid out as a grid in a local HTML page, screenshotted by Playwright, show what moved. Judge long frames on a build only: the dev server's React is several times slower (on the build, none during the tour, two at load). Slice 26 recorded the tour the same way, then a source's pop-over opened, switched to another name and closed; the tour's longest answer, 840 words in the rich-text step, is the one to watch for long frames. A hidden Browser pane draws no frames. The build replaces `.next`, so stop any server on it first, and end the `next start` process a stopped command leaves on its port. @@ -45,7 +46,7 @@ ## Copying a section (`prd/copy-section.tsx`, slice 17) - **Its form** is opencode's copy button (`docs/decisions.md`, Design, 2026-10-21): 24px, a 14px grey icon, no border, `hover:bg-foreground/10` and `active:bg-foreground/20`, a sibling of the `

` after it, so the heading's and the region's names don't change. It copies `sectionMarkdown` from `prd/prd-markdown.ts`: the download's form for one section, footnotes numbered within it. - **When it shows:** the section is `group/section`; the button's wrapper is `opacity-0` under `@media (hover: hover)` and `opacity-100` on `group-hover/section` or `group-focus-within/section`, else always (a touch screen). Tailwind 4 already puts `hover:` and `group-hover:` inside `@media (hover: hover)` (upgrade guide, read 2026-10-21 through Context7). It stays clickable while transparent, as opencode's does. -- **Its label** (`.copy-tip` in `src/app/globals.css`) is CSS, `aria-hidden`: shown on `.copy-button:hover` after 0.4s, closed by `data-pressed` from the press until the pointer leaves, held open by `data-open` to say "Couldn't copy". +- **Its label** (`.copy-tip` in `src/app/globals.css`) is CSS, `aria-hidden`: shown on `.copy-button:hover` after 0.4s, closed by `data-pressed` from the press until the pointer leaves, held open by `data-open` to say "Couldn't copy". It ends where the button ends instead of centred over it: beside a wrapped heading the button sits at the PRD's right edge, and the label, laid out even while hidden, reached past the page's 24px of padding and made it scroll sideways (found 2026-10-23, slice 27). - **After a copy** the name is "Copied" or "Couldn't copy" for `COPY_OUTCOME_MS`, and an `aria-live="polite"` span says it once, since screen readers don't reliably announce a focused button's new name. Not `role="status"`: the chat's status line is the page's one. `navigator.clipboard.writeText` rejects when refused (MDN, read 2026-10-21 through Context7), and a page that isn't a secure context has no `navigator.clipboard`; both read "Couldn't copy". The desktop app's Browser pane refuses the clipboard while it's hidden, so a successful copy is seen only in the end-to-end tests there (found 2026-10-21). ## The scenario tour (`workspace/scenario-tour.tsx`, slice 24) diff --git a/src/features/chat/chat.tsx b/src/features/chat/chat.tsx index b883d03..8b19542 100644 --- a/src/features/chat/chat.tsx +++ b/src/features/chat/chat.tsx @@ -215,13 +215,14 @@ export function Chat({ Chat with the assistant

- {/* 8px of room on the left, as far as its margin pulls it out, so - what moves past the start of a line, as a saved line's tick + {/* It centres the chat's column in its padding (`chat-column-pane`), + with 8px more room on the left, as far as its margin pulls it out, + so what moves past the start of a line, as a saved line's tick pulsing, isn't cut off where the pane scrolls. */}
{quotaUsedUp && ( - + )} {offered !== undefined && ( @@ -277,7 +280,7 @@ export function Chat({ role="group" aria-labelledby="suggestion-title" className={cn( - "space-y-2", + "chat-column space-y-2", suggestionArrives && "arrive arrive-block", )} > @@ -301,7 +304,7 @@ export function Chat({ event.preventDefault(); send(); }} - className="space-y-2" + className="chat-column space-y-2" >