diff --git a/docs/plan.md b/docs/plan.md index 3db8ff1..d892cce 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -25,12 +25,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:** the GIF's click, on `docs/readme-video-link` (`.claude/worktrees/readme-video`), from `main` at 8c410ca, waiting for the owner's push and pull request. The README's GIF is merged (d8e39c8, with 42f2002 recording slice 24's merge and 8c410ca planning slices 25 and 26), but its click opened GitHub's file page for the MP4, which doesn't play videos ("we can't show files that are this big right now", at 2.5 MB), and the raw file downloads as `application/octet-stream` (checked 2026-10-22). So the click now opens the same MP4 through GitHub's own video hosting: the owner uploaded it on 2026-10-22 (https://github.com/user-attachments/assets/e7412df8-9fca-4f2a-b3f0-89b2c3291125; GitHub, *Attaching files*: MP4 up to 10 MB on a free plan, H.264 recommended, public for a public repository, read 2026-10-22 through Context7). Until it's posted, the upload is the uploader's alone: signed out, the link answered 404 (2026-10-22), so the pull request's description carries the link, which publishes it; check that it plays signed out before the merge. The GIF is `docs/media/prd-assistant-demo.gif` (960×540, 15 fps, 4.8 MB, looping), and the MP4 stays in the repository as the source copy, `docs/media/prd-assistant-demo.mp4` (1920×1080, 30 fps, 318 frames, silent). Both are a 10.6-second motion piece about the interface, made in Remotion 4.0.538 (free for an individual under its licence, checked 2026-10-22) in its own project next to this one, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository: the owner chose, on 2026-10-22, the interface's look and behaviour on black over a recording of the app, with invented sample content, so the README calls it an illustration. 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`. Checked: the frame counts and sizes, and every frame around each transition from the MP4. Nobody has seen it play on GitHub yet. +- **Doing:** slice 25, the shared motion values and what arrives, is done on `feat/motion-arrivals` (`.claude/worktrees/slice-25-motion`), from `main` at a4d5574 (the GIF's click merged), waiting for the owner's look, push and merge. Local checks on 2026-10-22: `verify` (574 unit tests, 20 new) and `test:e2e` (77, 8 new, which then passed five more runs each) passed; two recordings of the whole scenario tour on a production build, read frame by frame around each arrival (`src/features/AGENTS.md`, *Checking motion by recording it*): the chat and then the PRD rising in as the intro gives way, the visitor's message springing from its corner, each line, the saved line's tick drawing itself with its ring, the statements one after another, the tables, the notices and Jump to latest, all settling without a jump, the conversation above them still; nothing arriving after a reload; no long animation frames during the tour, two at page load. Fixed on the way: a folded run's hidden labels stretched the chat below its last line (on `main` too: blank space under a run that was the answer's last line), the chat sank back as an arriving last line settled, and a pane's rise and delay passed to everything in it. It changes no file in `src/server` or `src/lib`, so nothing the model receives. +- **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 25, then slice 26: the app's motion after the demo video (Design, 2026-10-22), each on its own branch from `main` once this branch is merged; one session each. + 1. Slice 26, the words, the fold and the pop-over, on its own branch from `main` once `feat/motion-arrivals` is merged; one session. Its curves are in `src/app/globals.css` already (the pop-over's spring and fade). 2. Slice 8, the rate limit, once 9d is done and the owner has decided its legal basis and size (*Deferred*). -- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' fade; the copy button, its label and the clipboard; Inter and its resized fallback, which needs Arial installed, so Linux and Android show the system's font until Inter arrives; a table's cells keeping their 144px and its box's sideways scroll; the Thinking line's band). On staging, a real reply's table, and the Thinking line through the real model's waits with the times it shows (the fake model's are about 0s). On staging, a reply that arrives whole fading in as one, and a section's copy over HTTPS pasted into another app. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token. -- **Waiting on the owner:** pushing `docs/readme-video-link` and opening its pull request with the video's link in the description, then the merge once the link plays signed out; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. +- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' fade; 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 fading in as one, and a section's copy over HTTPS pasted into another app. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token. +- **Waiting on the owner:** a look at the motion (`npm run dev:replay` and its tour, from the worktree on port 3200), then pushing `feat/motion-arrivals`, 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. Merged and identical to `main`, `docs/readme-video-link` and its worktree `.claude/worktrees/readme-video` can go; this session, fenced into its own worktree, couldn't remove another. ### 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. @@ -121,9 +122,9 @@ ### 24 · A tour of every state the chat and the PRD can show · risk: normal · status: done (8a03a22) - + -### 25 · Motion from the demo video: the shared curves and arrivals · risk: normal · status: todo +### 25 · Motion from the demo video: the shared curves and arrivals · risk: normal · status: done (on `feat/motion-arrivals`; its id on `main` goes here after the merge) - **Goal:** what appears in the chat and the PRD arrives the way it does in the README's video, not in a jump. - **Acceptance:** - One set of motion values in `src/app/globals.css`: the video's springs replayed with `linear()`, its two eases, and the durations; the fold's slide, the rolling count, the copy label and the just-changed highlight use them too. diff --git a/src/app/globals.css b/src/app/globals.css index ed93d18..de59b27 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -19,6 +19,68 @@ --bubble: #262626; } +/* + * Motion, after the README's demo video (docs/decisions.md, Design, + * 2026-10-22; its project's `src/ui/motion.ts` and `src/Showcase.tsx`, made at + * 30 frames a second and at about twice the app's size, so its distances and + * blurs are halved where they're used). Every animation in the app takes its + * curve and its length from here. + */ +:root { + /* Its two eases: what arrives settles on the first, and what changes in + place (a count, a highlight, a tick drawing) turns on the second. */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + /* How long its springs take to settle, as Remotion's `measureSpring` says: + small pieces (damping 16, mass 0.7, stiffness 120), the pop-over (15, + 0.6, 140) and larger panes (18, 0.9, 90). */ + --spring-small-duration: 0.4s; + --spring-popover-duration: 0.5s; + --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: 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; +} + +/* + * Its springs (Remotion's `spring()`), sampled from Remotion 4.0.538's own + * code into `linear()`, within 0.004, over the lengths above. What arrives + * fades in on a curve of its own, as the video's things do: its opacity is + * the motion's progress times 1.4, so it's whole once the motion is 71% done + * and only the blur still settles (times 1.3 for a pane, 1.5 for the + * pop-over). One curve a line, as there's no reading them stop by stop. + */ +/* prettier-ignore */ +:root { + --ease-out-fade: linear(0, 0.0943 1.1%, 0.1844 2.2%, 0.2701 3.3%, 0.3476 4.35%, 0.4209 5.4%, 0.4899 6.45%, 0.5546 7.5%, 0.6179 8.6%, 0.6768 9.7%, 0.7315 10.8%, 0.7843 11.95%, 0.8329 13.1%, 0.9223 15.5%, 1 18%, 1); + --spring-small: linear(0, 0.0049 1.95%, 0.0194 4%, 0.0437 6.2%, 0.0781 8.6%, 0.1161 10.85%, 0.164 13.4%, 0.4431 26.95%, 0.5174 30.8%, 0.5818 34.4%, 0.65 38.6%, 0.7098 42.75%, 0.7627 46.95%, 0.8087 51.2%, 0.8519 55.95%, 0.8889 60.95%, 0.9197 66.2%, 0.945 71.8%, 0.9649 77.8%, 0.9801 84.35%, 0.9911 91.65%, 1); + --spring-small-fade: linear(0, 0.0076 2.05%, 0.0311 4.3%, 0.0703 6.7%, 0.1263 9.35%, 0.1816 11.6%, 0.2503 14.15%, 0.5782 25.45%, 0.7321 31.1%, 0.8096 34.2%, 0.8793 37.2%, 0.9446 40.25%, 1 43.1%, 1); + --spring-popover: linear(0, 0.0058 1.45%, 0.0239 3.05%, 0.055 4.8%, 0.0989 6.7%, 0.1418 8.3%, 0.1946 10.1%, 0.4215 17.3%, 0.522 20.65%, 0.6174 24.15%, 0.6988 27.55%, 0.7656 30.8%, 0.8219 34.05%, 0.8703 37.45%, 0.9101 40.95%, 0.9454 45.05%, 0.9722 49.45%, 0.9915 54.3%, 1.0039 59.75%, 1.0097 65.2%, 1.0114 71.8%, 1); + --spring-popover-fade: linear(0, 0.0024 0.75%, 0.0092 1.5%, 0.021 2.3%, 0.0369 3.1%, 0.084 4.85%, 0.1502 6.75%, 0.2148 8.35%, 0.2965 10.2%, 0.6207 17.05%, 0.7591 20.1%, 0.8889 23.2%, 1 26.15%, 1); + --spring-pane: linear(0, 0.005 1.35%, 0.0202 2.8%, 0.0459 4.4%, 0.0834 6.2%, 0.1628 9.35%, 0.3599 16.4%, 0.4517 19.85%, 0.5411 23.55%, 0.6179 27.15%, 0.6819 30.6%, 0.7383 34.15%, 0.7877 37.85%, 0.8302 41.7%, 0.872 46.45%, 0.9064 51.6%, 0.9339 57.2%, 0.9555 63.45%, 0.9716 70.4%, 0.9832 78.45%, 0.9911 88%, 1); + --spring-pane-fade: linear(0, 0.0052 1.2%, 0.0212 2.5%, 0.0481 3.9%, 0.0856 5.4%, 0.1361 7.1%, 0.2029 9.1%, 0.4536 16%, 0.5535 18.85%, 0.6343 21.3%, 0.7064 23.65%, 0.7942 26.8%, 0.8717 29.95%, 0.9403 33.15%, 1 36.4%, 1); +} + +/* Without `linear()` (before Chrome 113, Firefox 112 and Safari 17.2, MDN's + compatibility data, read 2026-10-22 through Context7), everything settles + on the ease-out instead. */ +@supports not (animation-timing-function: linear(0, 1)) { + :root { + --ease-out-fade: var(--ease-out); + --spring-small: var(--ease-out); + --spring-small-fade: var(--ease-out); + --spring-popover: var(--ease-out); + --spring-popover-fade: var(--ease-out); + --spring-pane: var(--ease-out); + --spring-pane-fade: var(--ease-out); + } +} + @theme inline { --color-background: var(--background); --color-surface: var(--surface); @@ -35,6 +97,9 @@ --radius-control: 0.25rem; /* The workspace's page: wider than 1280px, so the PRD gets its full page. */ --container-workspace: 100rem; + /* 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); } html { @@ -88,10 +153,13 @@ body { } /* - * The lines a folded line opens to slide open and shut in about 0.35s, like - * opencode's spring without bounce: the one grid row grows from 0fr to 1fr. - * Closed, they're hidden once the slide has ended. With reduced motion they - * show and hide at once. + * The lines a folded line opens to slide open and shut as something arriving + * does, without bounce: the one grid row grows from 0fr to 1fr. Closed, + * they're hidden once the slide has ended. With reduced motion they show and + * hide at once. The box that clips them is positioned, so it clips their + * screen readers' labels too (`sr-only` is absolute): placed against the + * chat's pane instead, they stretched it below its last line while the + * folded line was the answer's last. */ .fold { display: grid; @@ -105,6 +173,7 @@ body { } .fold > * { + position: relative; min-height: 0; overflow: hidden; } @@ -112,13 +181,13 @@ body { @media (prefers-reduced-motion: no-preference) { .fold { transition: - grid-template-rows 0.35s cubic-bezier(0.22, 1, 0.36, 1), - visibility 0s 0.35s; + grid-template-rows var(--duration-arrive) var(--ease-out), + visibility 0s var(--duration-arrive); } .fold[data-open] { transition: - grid-template-rows 0.35s cubic-bezier(0.22, 1, 0.36, 1), + grid-template-rows var(--duration-arrive) var(--ease-out), visibility 0s; } } @@ -143,17 +212,190 @@ body { } } +/* + * What appears after the page has shown arrives as in the video: it rises a + * few pixels, sharpens from a slight blur and fades in, then stays still: two + * animations, its motion and its fade, each on its own curve. The classes go + * only on what mounts after the page loaded (`useArrival` in + * `src/components/arrival.ts`, and the conversation's own record of what it + * showed with), so what a reload brings back is there at once. `--order` + * counts things that arrive one after another, and `data-again` starts an + * arrival again by switching to copies of its keyframes, so nothing remounts. + * `backwards` holds the first frame through a delay, and nothing stays once + * it has run. Text and names are there from the start, for tests and screen + * readers alike. With reduced motion everything is there at once. + */ +/* Each arrival's own: a pane's would otherwise pass on to what arrives in it. */ +@property --arrive-x { + syntax: "*"; + inherits: false; + initial-value: 0px; +} + +@property --arrive-y { + syntax: "*"; + inherits: false; + initial-value: 0.3125rem; +} + +@property --arrive-scale { + syntax: "*"; + inherits: false; + initial-value: 1; +} + +@property --arrive-blur { + syntax: "*"; + inherits: false; + initial-value: 4px; +} + +@property --order { + syntax: "*"; + inherits: false; + initial-value: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .arrive { + animation-name: arrive, arrive-fade; + animation-duration: var(--duration-arrive); + animation-timing-function: var(--ease-out), var(--ease-out-fade); + animation-delay: calc(var(--order) * var(--duration-stagger)); + animation-fill-mode: backwards; + } + + .arrive[data-again] { + animation-name: arrive-again, arrive-fade-again; + } + + /* A block, such as a table or a statement, rises a little further. */ + .arrive-block { + --arrive-y: 0.5rem; + } + + /* A search slides in from the left, fading as it moves, as the video's + do. */ + .arrive-search { + --arrive-x: -0.9375rem; + --arrive-y: 0; + --arrive-blur: 3px; + animation-timing-function: var(--ease-out); + } + + /* The visitor's message springs up from its corner. */ + .arrive-message { + --arrive-y: 0.8125rem; + --arrive-scale: 0.9; + transform-origin: 100% 100%; + animation-duration: var(--spring-small-duration); + animation-timing-function: var(--spring-small), var(--spring-small-fade); + } + + /* The chat and the PRD when the intro gives way to them. */ + .arrive-pane { + --arrive-y: 0.5625rem; + animation-duration: var(--spring-pane-duration); + animation-timing-function: var(--spring-pane), var(--spring-pane-fade); + } +} + +@keyframes arrive { + from { + transform: translate(var(--arrive-x), var(--arrive-y)) + scale(var(--arrive-scale)); + filter: blur(var(--arrive-blur)); + } +} + +@keyframes arrive-fade { + from { + opacity: 0; + } +} + +@keyframes arrive-again { + from { + transform: translate(var(--arrive-x), var(--arrive-y)) + scale(var(--arrive-scale)); + filter: blur(var(--arrive-blur)); + } +} + +@keyframes arrive-fade-again { + from { + opacity: 0; + } +} + +/* + * The tick before a saved section's line (`chat/section-step.tsx`), the + * video's check: its circle draws itself from the top in 12 frames, the tick + * from frame 8 in 10, and one ring pulses out from frame 10 in 18, growing + * 7px all round as it fades. Only on a line that arrives; brought back after + * a reload, or with reduced motion, it's simply there. + */ +@media (prefers-reduced-motion: no-preference) { + .tick-draws circle, + .tick-draws path { + stroke-dasharray: var(--length); + animation: tick-draw var(--duration-arrive) var(--ease-in-out) backwards; + } + + .tick-draws circle { + --length: 41; + } + + .tick-draws path { + --length: 9; + animation-duration: 0.33s; + animation-delay: 0.27s; + } + + .tick-draws::after { + content: ""; + position: absolute; + inset: 0; + border: 1px solid var(--foreground); + border-radius: 50%; + opacity: 0; + animation: tick-ring 0.6s var(--ease-out) 0.33s; + } +} + +@keyframes tick-draw { + from { + stroke-dashoffset: var(--length); + } +} + +@keyframes tick-ring { + from { + opacity: 0.6; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(1.875); + } +} + /* * A PRD section just saved, or saved again with different statements: a soft - * highlight behind it that fades to the page in about two seconds, with no - * border. A fade isn't motion, so it shows with reduced motion too. + * highlight behind it, with no border, that glows as a statement does in the + * video, rising in 6 frames, then fading to the page from its 8th to its + * 34th. A fade isn't motion, so it shows with reduced motion too. */ .just-changed { - animation: just-changed 2s ease-in both; + animation: just-changed var(--duration-highlight) var(--ease-in-out) both; } @keyframes just-changed { from { + background-color: transparent; + } + 17.65%, + 23.53% { background-color: rgb(255 255 255 / 0.1); } to { @@ -181,12 +423,12 @@ body { display: block; position: absolute; inset: 0; - animation: roll-out-up 0.3s ease-out both; + animation: roll-out-up var(--duration-roll) var(--ease-in-out) both; } .roll-in { display: inline-block; - animation: roll-in-up 0.3s ease-out both; + animation: roll-in-up var(--duration-roll) var(--ease-in-out) both; } .roll[data-direction="down"] .roll-out { @@ -225,10 +467,10 @@ body { /* * The small label above a section's copy button (opencode's tooltip, as CSS): * it shows 4px above the button once the pointer has rested on it for 0.4s, - * in 0.12s, 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. + * 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. */ .copy-tip { position: absolute; @@ -248,8 +490,8 @@ body { opacity: 0; visibility: hidden; transition: - opacity 0.12s, - visibility 0s 0.12s; + opacity var(--duration-quick) var(--ease-in-out), + visibility 0s var(--duration-quick); } @media (hover: hover) { @@ -257,7 +499,7 @@ body { opacity: 1; visibility: visible; transition: - opacity 0.12s 0.4s, + opacity var(--duration-quick) var(--ease-in-out) 0.4s, visibility 0s 0.4s; } } @@ -266,7 +508,7 @@ body { opacity: 1; visibility: visible; transition: - opacity 0.12s, + opacity var(--duration-quick) var(--ease-in-out), visibility 0s; } diff --git a/src/components/arrival.ts b/src/components/arrival.ts new file mode 100644 index 0000000..84b4eb3 --- /dev/null +++ b/src/components/arrival.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useState } from "react"; + +/** + * Whether what a component shows arrives as it appears (`.arrive` in + * `src/app/globals.css`): unless it was showing when the component mounted + * and has stayed since, so what a pane shows with, such as a conversation + * brought back after a reload, is there at once. `shown` is false or + * undefined while nothing shows, and may name what shows: a new name + * arrives too, as when the check-in moves to the next section. Kept from + * render to render with React's "storing information from previous renders" + * pattern (https://react.dev/reference/react/useState). + */ +export function useArrival(shown: string | boolean | undefined): boolean { + const showing = shown === false || shown === "" ? undefined : shown; + const [atMount, setAtMount] = useState(showing); + if (atMount !== undefined && showing !== atMount) setAtMount(undefined); + return showing !== undefined && atMount === undefined; +} diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index bd7c9df..7f3f3fc 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -3,14 +3,14 @@ ## 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 fade restarts without touching the heading's focus, a white tint fading to transparent in 2s, which runs with reduced motion too, since a fade doesn't move. The sections the PRD mounts with, such as those brought back after a reload, and a save that changes nothing, don't count (`useChanges` in `prd/prd-document.tsx`, React's "storing information from previous renders" pattern). The title line's count rolls (`RollingNumber`, `.roll` in `src/app/globals.css`): plain text at rest, so a text query still finds the whole line; during a change the old number leaves and the new one comes in, clipped with `clip-path: inset(0)`, since `overflow: hidden` would move an inline block's baseline (MDN, `clip-path`, read 2026-10-20 through Context7). With reduced motion the old number is `display: none` and no `animationend` comes, so the rolling markup stays until the next change. jsdom has no `AnimationEvent`, so React listens for a vendor-prefixed event there; `tests/unit/animation-event.ts` defines one before React loads. +- **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. - **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:`; its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). Interpolating the row from `0fr` was checked in Chromium only (2026-10-20); where it doesn’t interpolate, the lines appear at once. Rows are `leading-7`, 8px apart, 4px under the button. While a run’s search runs, the run’s span carries `.shimmer`, and its white parts drop their colour, since `text-foreground` would cover the band. The fake model searches three times when a message holds `FAKE_SEARCH_AGAIN`, which the end-to-end tests use. A run takes in the thought lines (below) in its unbroken stretch of searches and thoughts, before, between and after the searches, as opencode's `groupContent` does; a stretch with one search stays plain lines, and the count counts searches only. +- **The search lines** (slice 15, `chat/search-step.tsx`, `chat/search-run.tsx`): opencode’s form at 50c5fce (`docs/decisions.md`, Design, 2026-10-20). Each search is a plain `

`, never a button: "Search" in medium weight, the query and what came of it in grey, 13px, one line cut off with `truncate` (a failure keeps its whole reason). The dot is `aria-hidden` and an `sr-only` "passages from" goes before the names. Searches in a row (`laidOut` in `chat/conversation.tsx`) fold into `SearchRun`: a button "Used 3 searches" with `aria-expanded`, whose count is the shared `RollingNumber` (`src/components/rolling-number.tsx`, also the PRD’s), then an inline SVG triangle, `-rotate-90` and `opacity-45` at rest, full on hover and turned down when open, through Tailwind’s `group-aria-expanded:`; its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). 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. - **How long the model thought** (slice 20, `chat/thoughts.ts`, `chat/thought-line.tsx`): opencode's form at 50c5fce (`docs/decisions.md`, Design, 2026-10-21): "Thinking" while it thinks, 13px medium on a 16px line with `.shimmer`, then "Thought" in medium weight and white with the time in grey and tabular figures, as "Thought 8s", and from a minute "1m 5s", whole seconds rounded (opencode's `AssistantReasoningContent` in `packages/session-ui/src/message/message-content.tsx` and its words in `packages/ui/src/i18n/en.ts`, read 2026-10-21). The line doesn't open. The server can't time the reasoning, since the chat model's replies arrive whole, so the browser times each wait for a reply: `TimedTransport` in `workspace/workspace.tsx` pipes each answer's stream through `timeThoughts`, which starts the first wait at the request and each later one when the last step running finishes (a tool output), and puts a `data-thought` chunk with `{ ms }` just before the chunk that begins the next reply (text or a tool's input). The AI SDK keeps it as a part of the answer in that place (any `data-*` chunk without `transient`, ai 7.0.143, `processUIMessageStream`), so it's stored and restored with the conversation; a wait the answer ends in, stopped or failed, gets none. The parts go back to the server with the conversation, where `safeValidateUIMessages` accepts any `data-*` part and `convertToModelMessages` drops it unless given `convertDataPart`, so the model's input doesn't change (`tests/unit/chat.test.ts` compares it with and without the times). `finishedOnly` counts a thought as nothing finished. An answer kept before slice 20 has none and shows none, so `STORED_FORMAT` stayed 2: the new part only adds to what fits. -- **New text fades in** (slice 16, `chat/rich-text.tsx`): opencode's `markdown-word-enter` at 50c5fce, an opacity fade of 180ms (`.word-enter` in `src/app/globals.css`, only under `prefers-reduced-motion: no-preference`). Only an answer that came in after the conversation mounted fades (`shownAtFirst` in `chat/conversation.tsx`), never one it mounted with, such as a conversation brought back after a reload; this, not the chat's status, decides it, since an answer's last text can arrive in the same render that ends the answer. `RichText` keeps where each update's words begin (`starts`, in characters shown, React's "storing information from previous renders" pattern) and wraps each update's words in one span per run, so a reply that arrives in one piece, as on staging, fades as one and is still one text node for `getByText`; the fake model's words, one per 15ms, fade by groups. `Arrival` keeps from its mount whether it fades: the next update doesn't cut a fade short, and words mounted anew by a change of form (a table taking shape) don't fade again. Under reduced motion (`useReducedMotion`, `chat/use-reduced-motion.ts`, a `useSyncExternalStore` on `matchMedia`, which jsdom lacks, so there nothing asks for it) the text has no spans at all. The conversation stays out of any live region. axe reads a word mid-fade as low contrast, so `tests/e2e/accessibility.ts` finishes every fade while axe reads. +- **New text fades in** (slice 16, `chat/rich-text.tsx`): opencode's `markdown-word-enter` at 50c5fce, an opacity fade of 180ms (`.word-enter` in `src/app/globals.css`, only under `prefers-reduced-motion: no-preference`). Only an answer that came in after the conversation mounted fades (`shownAtFirst` in `chat/conversation.tsx`), never one it mounted with, such as a conversation brought back after a reload; this, not the chat's status, decides it, since an answer's last text can arrive in the same render that ends the answer. `RichText` keeps where each update's words begin (`starts`, in characters shown, React's "storing information from previous renders" pattern) and wraps each update's words in one span per run, so a reply that arrives in one piece, as on staging, fades as one and is still one text node for `getByText`; the fake model's words, one per 15ms, fade by groups. `Arrival` keeps from its mount whether it fades: the next update doesn't cut a fade short, and words mounted anew by a change of form (a table taking shape) don't fade again. Under reduced motion (`useReducedMotion`, `chat/use-reduced-motion.ts`, a `useSyncExternalStore` on `matchMedia`, which jsdom lacks, so there nothing asks for it) the text has no spans at all. The conversation stays out of any live region. axe reads a word mid-fade as low contrast, so `tests/e2e/accessibility.ts` finishes every 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 `
` 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 ` @@ -237,13 +268,18 @@ export function Chat({ )} - {quotaUsedUp && } + {quotaUsedUp && ( + + )} - {suggestion !== undefined && !quotaUsedUp && ( + {offered !== undefined && (

Now try to break it. Send a claim that nobody in the interviews @@ -252,10 +288,10 @@ export function Chat({

)} diff --git a/src/features/chat/conversation.tsx b/src/features/chat/conversation.tsx index 5aad4d3..3f377c0 100644 --- a/src/features/chat/conversation.tsx +++ b/src/features/chat/conversation.tsx @@ -75,8 +75,9 @@ function activityOf(parts: ShownPart[]): Activity { * reply follows a line with how long the model thought before it. An * answer that broke off stays visible, marked as unfinished, until it's * retried or replaced. An answer the visitor stopped keeps what it finished - * and says so. The text of an answer that came in after the chat showed - * fades in as it arrives. + * and says so. What came in after the chat showed arrives (`.arrive` in + * `src/app/globals.css`): the visitor's message springs up from its corner, + * each line rises in as it appears, and the text fades in. */ export function Conversation({ messages, @@ -92,7 +93,7 @@ export function Conversation({ onSeeSection?: (section: SectionName) => void; }) { // The messages the chat showed with, such as a conversation brought back - // after a reload: their text is there already, so it doesn't fade in. + // after a reload: they're there already, so nothing in them arrives. const [shownAtFirst] = useState( () => new Set(messages.map((message) => message.id)), ); @@ -114,6 +115,7 @@ export function Conversation({ className="[&>[data-role=user]+[data-role=assistant]]:mt-4 [&>li+li]:mt-8" > {messages.map((message, index) => { + const arriving = !shownAtFirst.has(message.id); if (message.role === "user") { // Stopped before its answer's first part arrived. const stoppedEarly = @@ -123,7 +125,10 @@ export function Conversation({
  • You

    @@ -131,7 +136,12 @@ export function Conversation({

  • {stoppedEarly && ( - + )}
    ); @@ -141,7 +151,7 @@ export function Conversation({

    Assistant

    @@ -270,6 +281,7 @@ function AssistantMessage({ parts={laid.run} stopped={stopped} moving={moving} + arriving={arriving} className={space} /> ); @@ -277,7 +289,14 @@ function AssistantMessage({ const { part, first: index } = laid; const step = cn(space, moving && "shimmer"); if (isThought(part)) { - return ; + return ( + + ); } switch (part.type) { case "text": @@ -295,6 +314,7 @@ function AssistantMessage({ key={part.toolCallId} part={part} stopped={stopped} + arriving={arriving} className={step} /> ); @@ -304,6 +324,7 @@ function AssistantMessage({ key={part.toolCallId} part={part} stopped={stopped} + arriving={arriving} onSee={onSeeSection} className={step} /> @@ -312,7 +333,7 @@ function AssistantMessage({ return (

    {failedStep(part.toolName)} didn't work. {part.errorText}

    @@ -323,12 +344,18 @@ function AssistantMessage({ )} {unfinished && ( -

    +

    This answer stopped before it finished.

    )} {stopped && ( -

    0 && "mt-6")}> +

    0 && "mt-6", + arrive, + )} + > You stopped this answer.

    )} diff --git a/src/features/chat/rich-text.tsx b/src/features/chat/rich-text.tsx index ae0de90..7632ff5 100644 --- a/src/features/chat/rich-text.tsx +++ b/src/features/chat/rich-text.tsx @@ -25,7 +25,8 @@ import { useReducedMotion } from "./use-reduced-motion"; * mounts with, which is all of a reply that arrives in one piece, and then * only the words each update adds, each update's words as one. Text that was * there before, such as a finished answer or one brought back after a - * reload, and everything under reduced motion, shows at once. + * reload, and everything under reduced motion, shows at once. A table that + * arrives rises in as a block (`.arrive`) as it takes shape. */ export function RichText({ text, @@ -70,7 +71,11 @@ export function RichText({ ); } if (block.type === "table") { - return {table(block, words)}; + return ( + + {table(block, words)} + + ); } const List = block.ordered ? "ol" : "ul"; return ( @@ -190,7 +195,13 @@ function table({ header, rows }: Table, words: Words | undefined) { * A `ResizeObserver` measures it whenever the box or the table changes size, * as the chat narrows or an arriving table grows. */ -function SideScroll({ children }: { children: ReactNode }) { +function SideScroll({ + arriving, + children, +}: { + arriving: boolean; + children: ReactNode; +}) { const box = useRef(null); const [scrolls, setScrolls] = useState(false); useEffect(() => { @@ -206,7 +217,7 @@ function SideScroll({ children }: { children: ReactNode }) { return (
    ; @@ -29,6 +31,8 @@ export function SearchRun({ stopped?: boolean; /** The line in progress carries the moving band (`.shimmer`). */ moving?: boolean; + /** Its answer came in after the chat showed. */ + arriving?: boolean; className?: string; }) { const [open, setOpen] = useState(false); @@ -68,12 +72,18 @@ export function SearchRun({
    {parts.map((part, index) => isThought(part) ? ( - + ) : ( ), diff --git a/src/features/chat/search-step.tsx b/src/features/chat/search-step.tsx index 8eb05f1..69239f5 100644 --- a/src/features/chat/search-step.tsx +++ b/src/features/chat/search-step.tsx @@ -9,16 +9,21 @@ const SHOWN_QUERY_LENGTH = 80; * One search the assistant ran, as a plain line that doesn't open, the way * opencode's desktop app shows a search tool: "Search" in medium weight, then * the query and what came of it in grey (docs/decisions.md, Design, - * 2026-10-20). The passages the PRD quotes open from its source names. + * 2026-10-20). The passages the PRD quotes open from its source names. A + * search that comes in slides in from the left as it arrives, as in the + * README's video, and stays the same line as it finishes. */ export function SearchStep({ part, stopped = false, + arriving = false, className, }: { part: SearchNotesPart; /** Its answer was stopped, so a search still running never finished. */ stopped?: boolean; + /** Its answer came in after the chat showed. */ + arriving?: boolean; className?: string; }) { const result = outcome(part, stopped); @@ -35,6 +40,7 @@ export function SearchStep({ // One line, cut off with an ellipsis, as opencode's; a failure keeps // its whole reason. part.state !== "output-error" && "truncate", + arriving && "arrive arrive-search", className, )} > diff --git a/src/features/chat/section-step.tsx b/src/features/chat/section-step.tsx index d492a11..e5618b3 100644 --- a/src/features/chat/section-step.tsx +++ b/src/features/chat/section-step.tsx @@ -6,28 +6,38 @@ import { cn } from "@/lib/utils"; /** * One section the assistant saved: one line saying what the check found, - * ending in "See it", which brings the section into view in the PRD. + * after a tick, ending in "See it", which brings the section into view in the + * PRD. In an answer that came in after the chat showed, each of its lines + * arrives as it appears, the check, then what came of it (keyed by what it + * says, so the next one is a new line), and the tick draws itself. */ export function SectionStep({ part, stopped = false, + arriving = false, onSee, className, }: { part: SaveSectionPart; /** Its answer was stopped, so a check still running never finished. */ stopped?: boolean; + /** Its answer came in after the chat showed. */ + arriving?: boolean; onSee?: (section: SectionName) => void; className?: string; }) { const name = part.input?.section ?? "the section"; - const line = cn("text-sm text-muted-foreground", className); + const line = cn( + "text-sm text-muted-foreground", + arriving && "arrive", + className, + ); switch (part.state) { case "input-streaming": case "input-available": return ( -

    +

    {stopped ? `Stopped checking the quotes in ${name}` : `Checking the quotes in ${name}…`} @@ -36,7 +46,7 @@ export function SectionStep({ case "output-error": return ( -

    +

    Saving {name} didn't work. {part.errorText}

    ); @@ -44,7 +54,8 @@ export function SectionStep({ case "output-available": { const section = part.output; return ( -

    +

    + Saved {summary(section)} {onSee && ( <> @@ -68,6 +79,39 @@ export function SectionStep({ } } +/** + * The tick before a saved section's line: the README video's check in a + * circle, at half its size. On a line that arrives it draws itself, circle + * first, with one soft pulse (`.tick-draws` in `src/app/globals.css`); + * otherwise it's simply there. Decoration: "Saved" says it. + */ +function Tick({ drawing }: { drawing: boolean }) { + return ( + + ); +} + /** "Goals / Objectives: 4 of 5 quotes confirmed, 1 statement cites nothing". */ function summary(section: CheckedSection): string { const { quotes, confirmed, flagged } = tally(section); diff --git a/src/features/chat/thought-line.tsx b/src/features/chat/thought-line.tsx index f272579..54a9d53 100644 --- a/src/features/chat/thought-line.tsx +++ b/src/features/chat/thought-line.tsx @@ -21,18 +21,24 @@ export function ThinkingLine({ className }: { className?: string }) { ); } -/** How long the model thought before a reply, as "Thought 8s". */ +/** + * How long the model thought before a reply, as "Thought 8s". In an answer + * that came in after the chat showed, it arrives. + */ export function ThoughtLine({ part, + arriving = false, className, }: { part: ThoughtPart; + arriving?: boolean; className?: string; }) { return (

    diff --git a/src/features/prd/copy-section.tsx b/src/features/prd/copy-section.tsx index 172e951..df99522 100644 --- a/src/features/prd/copy-section.tsx +++ b/src/features/prd/copy-section.tsx @@ -54,7 +54,7 @@ export function CopySection({ const label = outcome ? SAID[outcome] : "Copy section"; return ( - + )} @@ -145,12 +158,17 @@ export function PrdDocument({ key={index} section={name} statement={statement} + arrival={ + changed === undefined + ? undefined + : { change: changed, order: index } + } onOpenSource={setSource} /> ))} {checkIn && name === latest && ( - + )} ) : ( @@ -236,15 +254,20 @@ function quoteCount({ section }: SavedSection): string { /** * One statement, ending in its sources by name. Only what the code objects - * to is written out under it. + * to is written out under it. Once its section changes after the PRD showed, + * it arrives in its turn (`--order`), and again with each change after that, + * through `data-again`, without remounting what has focus. */ function Statement({ section, statement, + arrival, onOpenSource, }: { section: SectionName; statement: CheckedStatement; + /** How many times its section changed, and its place in the section. */ + arrival: { change: number; order: number } | undefined; onOpenSource: (source: StatementSource) => void; }) { const sources = sourcesOf(statement); @@ -252,7 +275,11 @@ function Statement({ (citation) => !citation.confirmed, ); return ( -

  • +
  • {statement.text} {sources.length > 0 && ( @@ -297,11 +324,17 @@ function CheckInButton({ failed, disabled, onConfirm, -}: CheckIn) { + arriving, +}: CheckIn & { arriving: boolean }) { // The section asked for says "Drafting…" beside its own heading. if (drafting) return null; return ( -

    +
    diff --git a/src/features/workspace/quota.tsx b/src/features/workspace/quota.tsx index df0410e..268822e 100644 --- a/src/features/workspace/quota.tsx +++ b/src/features/workspace/quota.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { site } from "@/lib/site"; +import { cn } from "@/lib/utils"; // Cloudflare's daily free allocation is shared by every visitor of the demo. // Once it's used up, nothing the assistant does can work until 00:00 UTC, so @@ -30,7 +31,7 @@ export function localTime( * The demo's free AI allocation for today is used up: when it resets, in UTC * and in the visitor's own time, and where to see the app working meanwhile. */ -export function QuotaNotice() { +export function QuotaNotice({ className }: { className?: string }) { // When the notice first showed; the reset is the next midnight after it. const [shownAt] = useState(() => new Date()); const reset = localTime(nextReset(shownAt)); @@ -38,7 +39,10 @@ export function QuotaNotice() { return (

    The demo's free AI allocation for today is used up, so the diff --git a/src/features/workspace/workspace.tsx b/src/features/workspace/workspace.tsx index 46f7a55..1dc2c43 100644 --- a/src/features/workspace/workspace.tsx +++ b/src/features/workspace/workspace.tsx @@ -10,8 +10,10 @@ import { useMemo, useRef, useState, + type CSSProperties, } from "react"; import { browser } from "react-dom"; +import { useArrival } from "@/components/arrival"; import { Chat, MESSAGE_BOX_ID } from "@/features/chat/chat"; import { timeThoughts } from "@/features/chat/thoughts"; import { PrdDocument } from "@/features/prd/prd-document"; @@ -29,6 +31,7 @@ import { type SectionName, } from "@/lib/prd"; import { FIRST_SECTION_REQUEST } from "@/lib/suggestions"; +import { cn } from "@/lib/utils"; import { breakItSuggestion, conversationStarted } from "./disclosure"; import { Intro, INTRO_TITLE_ID } from "./intro"; import { ScenarioTour } from "./scenario-tour"; @@ -140,6 +143,9 @@ function RestoredWorkspace() { const [introFailed, setIntroFailed] = useState(false); const onIntro = !leftIntro && !conversationStarted(messages); + // The chat and the PRD arrive when the intro gives way to them, not when + // the page opens on them with a kept conversation. + const panesArrive = useArrival(!onIntro); const working = status === "submitted" || status === "streaming"; const failed = status === "error" || (interrupted && status === "ready"); const sections = useMemo(() => savedSections(messages), [messages]); @@ -329,6 +335,11 @@ function RestoredWorkspace() { // On a laptop the chat keeps a steady width, 400 to 520px, and the PRD // takes the rest of the page (docs/decisions.md, Design, 2026-10-20). + // Arriving, the PRD follows the chat a beat later. + const pane = cn( + "flex flex-col lg:min-h-0", + panesArrive && "arrive arrive-pane", + ); return ( <>

    The chat and your PRD -
    +
    -
    +
    { const settle = () => { - for (const word of document.querySelectorAll(".word-enter")) - for (const fade of word.getAnimations()) fade.finish(); + for (const animation of document.getAnimations()) { + if (animation.effect?.getComputedTiming().endTime !== Infinity) { + animation.finish(); + } + } }; settle(); const observer = new MutationObserver(settle); - observer.observe(document.body, { childList: true, subtree: true }); - (window as Held).heldWords = observer; + observer.observe(document.body, { + childList: true, + subtree: true, + attributeFilter: ["class", "data-again"], + }); + (window as Held).heldMotion = observer; }); try { const { violations } = await new AxeBuilder({ page }) @@ -35,6 +44,6 @@ export async function expectNoAccessibilityViolations(page: Page) { ), ).toEqual([]); } finally { - await page.evaluate(() => (window as Held).heldWords?.disconnect()); + await page.evaluate(() => (window as Held).heldMotion?.disconnect()); } } diff --git a/tests/e2e/chat.spec.ts b/tests/e2e/chat.spec.ts index f7aa903..0c06f6d 100644 --- a/tests/e2e/chat.spec.ts +++ b/tests/e2e/chat.spec.ts @@ -252,6 +252,78 @@ test.describe("chat", () => { }); } + for (const reducedMotion of ["no-preference", "reduce"] as const) { + test(`brings in the visitor's message and each line of the answer, the saved line's tick drawing itself, unless motion is reduced, and nothing after a reload (${reducedMotion})`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion }); + await openChat(page); + await ask(page, "Draft the first section."); + await expect(conversation(page)).toContainText(FAKE_NOTE); + + const moving = (name: string) => + reducedMotion === "reduce" ? "none" : name; + const asked = conversation(page).locator(":scope > li").first(); + const search = conversation(page).getByText(/^Search “/); + const saved = conversation(page).locator("p", { + hasText: /^Saved Introduction \/ Overview/, + }); + await expect(asked).toHaveCSS( + "animation-name", + moving("arrive, arrive-fade"), + ); + await expect( + conversation(page) + .getByText(/^Thought \d+s$/) + .first(), + ).toHaveCSS("animation-name", moving("arrive, arrive-fade")); + await expect(search).toHaveCSS( + "animation-name", + moving("arrive, arrive-fade"), + ); + await expect(saved).toHaveCSS( + "animation-name", + moving("arrive, arrive-fade"), + ); + // The tick draws its circle, then itself. + await expect(saved.locator("circle")).toHaveCSS( + "animation-name", + moving("tick-draw"), + ); + await expect(saved.locator("path")).toHaveCSS( + "animation-name", + moving("tick-draw"), + ); + if (reducedMotion === "no-preference") { + await expect(asked).toHaveCSS("animation-duration", /^0\.4s/); + await expect(search).toHaveCSS("animation-duration", /^0\.4s/); + } + // Then still, sharp and whole, the tick drawn. + for (const line of [asked, search, saved]) { + await expect(line).toHaveCSS("opacity", "1"); + await expect(line).toHaveCSS("transform", "none"); + await expect(line).toHaveCSS("filter", "none"); + } + await expect(saved.locator("circle")).toHaveCSS( + "stroke-dashoffset", + "0px", + ); + + // Kept once the answer has ended, and brought back still. + await expect + .poll(() => + page.evaluate( + () => localStorage.getItem("prd-assistant:conversation") ?? "", + ), + ) + .toContain(FAKE_NOTE); + await page.reload(); + await expect(conversation(page)).toContainText(FAKE_NOTE); + await expect(conversation(page).locator(".arrive")).toHaveCount(0); + await expect(saved.locator("circle")).toHaveCSS("animation-name", "none"); + }); + } + test("stops an answer with Stop, keeping what the visitor typed and the focus, and the chat goes on, after a reload too", async ({ page, }) => { diff --git a/tests/e2e/intro.spec.ts b/tests/e2e/intro.spec.ts index c77616e..3c7cf47 100644 --- a/tests/e2e/intro.spec.ts +++ b/tests/e2e/intro.spec.ts @@ -138,6 +138,56 @@ test.describe("the intro", () => { await expect(conversation(page)).toHaveCount(0); }); + for (const reducedMotion of ["no-preference", "reduce"] as const) { + test(`the chat and the PRD arrive as it gives way, the PRD a beat later, unless motion is reduced, and not after a reload (${reducedMotion})`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion }); + await page.goto("/"); + await page + .getByRole("button", { name: "Or ask your own question first" }) + .click(); + + // Each region's pane, which rises in from a slight blur. + const chatPane = page + .getByRole("region", { name: "Chat with the assistant" }) + .locator(".."); + const prdPane = prd(page).locator(".."); + const arriving = + reducedMotion === "reduce" ? "none" : "arrive, arrive-fade"; + await expect(chatPane).toHaveCSS("animation-name", arriving); + await expect(prdPane).toHaveCSS("animation-name", arriving); + if (reducedMotion === "no-preference") { + await expect(chatPane).toHaveCSS("animation-duration", /^0\.77s/); + await expect(chatPane).toHaveCSS("animation-delay", /^0s/); + await expect(prdPane).toHaveCSS("animation-delay", /^0\.2s/); + } + // Then still, sharp and whole. + for (const pane of [chatPane, prdPane]) { + await expect(pane).toHaveCSS("opacity", "1"); + await expect(pane).toHaveCSS("transform", "none"); + await expect(pane).toHaveCSS("filter", "none"); + } + + await page + .getByRole("textbox", { name: "Your message" }) + .fill("Who wants a ping for every item?"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(conversation(page)).toContainText(FAKE_NOTE); + await expect(page.getByRole("button", { name: "Send" })).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => localStorage.getItem("prd-assistant:conversation") ?? "", + ), + ) + .toContain(FAKE_NOTE); + await page.reload(); + await expect(conversation(page)).toContainText(FAKE_NOTE); + await expect(page.locator(".arrive")).toHaveCount(0); + }); + } + test("when the first request fails, the intro comes back with a clear message and its button, which tries again", async ({ page, }) => { diff --git a/tests/e2e/layout.spec.ts b/tests/e2e/layout.spec.ts index cdf8c53..cdfc0c7 100644 --- a/tests/e2e/layout.spec.ts +++ b/tests/e2e/layout.spec.ts @@ -1,5 +1,9 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { + STORAGE_KEY, + STORED_FORMAT, +} from "@/features/workspace/stored-conversation"; import { FAKE_FAILURES, FAKE_SEARCH_AGAIN } from "@/server/ai/fake-chat-model"; import { expectNoAccessibilityViolations } from "./accessibility"; @@ -51,10 +55,17 @@ async function width(locator: Locator): Promise { /** * The gaps between an element's children, top to bottom, from one layout: * each child's top less the bottom of the one before. Children taken out of - * the flow, such as the screen readers' "Assistant", don't count. + * the flow, such as the screen readers' "Assistant", don't count. What + * arrives rises a few pixels as it comes (slice 25), so every animation that + * ends is finished first, and the gaps are the settled ones. */ function childGaps(parent: Locator): Promise { return parent.evaluate((element) => { + for (const animation of document.getAnimations()) { + if (animation.effect?.getComputedTiming().endTime !== Infinity) { + animation.finish(); + } + } const children = [...element.children].filter( (child) => getComputedStyle(child).position !== "absolute", ); @@ -305,6 +316,146 @@ test.describe("the workspace on a 1440px-wide laptop", () => { await expect(jump).toHaveCount(0); }); + test("ends the chat at its last line while an answer ends in folded searches", async ({ + page, + }) => { + // The folded lines' labels for screen readers are `absolute`: placed + // against the chat's pane instead of the fold that clips them, they + // stretched the pane below its last line, and the chat, following its + // end, showed blank space under it (found 2026-10-22). + const search = (id: string, query: string) => ({ + type: "tool-searchNotes", + toolCallId: id, + state: "output-available", + input: { query }, + output: { + passages: [ + { + id: 1, + slug: "henk", + participant: "Henk", + position: 4, + question: "How would you want to know when Ineke adds something?", + answer: "I'd like my phone to ping me every time.", + distance: 0.3, + }, + ], + }, + }); + await page.setViewportSize({ width: 1440, height: 480 }); + await page.addInitScript( + ({ key, kept }) => localStorage.setItem(key, JSON.stringify(kept)), + { + key: STORAGE_KEY, + kept: { + format: STORED_FORMAT, + messages: [ + { + id: "question", + role: "user", + parts: [{ type: "text", text: QUESTION }], + }, + { + id: "answer", + role: "assistant", + parts: [ + { type: "step-start" }, + { + type: "text", + text: Array.from( + { length: 12 }, + () => "Henk wants a ping for every item Ineke adds.", + ).join(" "), + state: "done", + }, + ], + }, + { + id: "again", + role: "user", + parts: [{ type: "text", text: "Search once more." }], + }, + { + id: "searches", + role: "assistant", + parts: [ + { type: "step-start" }, + search("first", "pings"), + { type: "step-start" }, + search("second", "notifications"), + ], + }, + ], + interrupted: false, + stopped: [], + }, + }, + ); + await page.goto("/"); + await expect( + turns(page).nth(3).getByRole("button", { name: "Used 2 searches" }), + ).toBeVisible(); + + const { scrollHeight, clientHeight, end } = await page + .locator("#chat-title + div") + .evaluate((pane) => { + const list = pane.querySelector(":scope > ol")!; + const top = pane.getBoundingClientRect().top - pane.scrollTop; + return { + scrollHeight: pane.scrollHeight, + clientHeight: pane.clientHeight, + end: list.getBoundingClientRect().bottom - top, + }; + }); + expect(scrollHeight).toBeGreaterThan(clientHeight); + expect(Math.abs(scrollHeight - end)).toBeLessThan(1); + }); + + test("keeps the conversation still while what arrives at its end rises into place", async ({ + page, + }, testInfo) => { + // Short enough that one answer fills the chat. + await page.setViewportSize({ width: 1440, height: 480 }); + await openChat(page); + await ask(page, "Draft the first section."); + await expect(turns(page).nth(1)).toContainText(ANSWER_END); + await expect(page.getByRole("button", { name: "Send" })).toBeVisible(); + + // Where the first answer sits, every frame, while the next one breaks + // off and its notice and the alert arrive as the chat's last lines. + // Following the chat's end, it only ever moves up. Following the pane's + // scroll height, which counts a rising line's few pixels, it sank back + // as the alert settled. + await page.evaluate(() => { + const first = document.querySelector( + "[aria-label=Conversation] > li:nth-child(2)", + )!; + const tops: number[] = []; + Object.assign(window, { tops }); + const sample = () => { + tops.push(first.getBoundingClientRect().top); + if (tops.length < 900) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + await ask( + page, + `Who wants a ping for every item? ${FAKE_FAILURES.partway} ${testInfo.testId}`, + ); + await expect(chat(page).getByRole("alert")).toBeVisible(); + await expect(chat(page).getByRole("alert")).toHaveCSS("opacity", "1"); + await expect(chat(page).getByRole("alert")).toHaveCSS("transform", "none"); + + const tops = await page.evaluate( + () => (window as unknown as { tops: number[] }).tops, + ); + const sank = Math.max( + ...tops.slice(1).map((top, frame) => top - tops[frame]!), + ); + expect(tops.length).toBeGreaterThan(10); + expect(sank).toBeLessThan(0.5); + }); + test("offers Jump to latest once opened searches push the chat's end out of view", async ({ page, }) => { diff --git a/tests/e2e/prd.spec.ts b/tests/e2e/prd.spec.ts index 3a40361..b0bf703 100644 --- a/tests/e2e/prd.spec.ts +++ b/tests/e2e/prd.spec.ts @@ -243,7 +243,7 @@ test.describe("the PRD", () => { await page.emulateMedia({ reducedMotion }); await draftFirstSection(page); const pane = page.locator(".bg-page"); - // Each number the count rolls in, as it appears: it's gone 0.3s later, + // Each number the count rolls in, as it appears: it's gone 0.27s later, // or stays still with reduced motion, as the first section's 1 does. await page.evaluate(() => { const seen: { text: string; animation: string }[] = []; @@ -275,9 +275,9 @@ test.describe("the PRD", () => { const goals = page.getByRole("region", { name: /^Goals \/ Objectives/ }); const highlight = goals.locator(".just-changed"); - // The fade runs with reduced motion too: it doesn't move. + // The glow runs with reduced motion too: it doesn't move. await expect(highlight).toHaveCSS("animation-name", "just-changed"); - await expect(highlight).toHaveCSS("animation-duration", "2s"); + await expect(highlight).toHaveCSS("animation-duration", "1.13s"); await expect(highlight).toHaveCSS("border-top-width", "0px"); await expect(prd(page)).toContainText("2 of 9 sections"); expect( @@ -302,6 +302,45 @@ test.describe("the PRD", () => { }); } + for (const reducedMotion of ["no-preference", "reduce"] as const) { + test(`brings in a saved section's statements one after another, and the check-in, unless motion is reduced, and nothing after a reload (${reducedMotion})`, async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion }); + await draftFirstSection(page); + + const items = statements(page, INTRODUCTION); + const checkIn = page.getByRole("button", { name: /^Looks right/ }); + const arriving = + reducedMotion === "reduce" ? "none" : "arrive, arrive-fade"; + await expect(items).toHaveCount(4); + for (const item of await items.all()) { + await expect(item).toHaveCSS("animation-name", arriving); + } + await expect(checkIn.locator("..")).toHaveCSS("animation-name", arriving); + if (reducedMotion === "no-preference") { + // 0.2s apart, as the video's statements are. + for (const [index, delay] of ["0s", "0.2s", "0.4s", "0.6s"].entries()) { + await expect(items.nth(index)).toHaveCSS( + "animation-delay", + new RegExp(`^${delay.replace(".", "\\.")}`), + ); + } + } + // Then still, sharp and whole. + for (const item of [items.first(), items.last()]) { + await expect(item).toHaveCSS("opacity", "1"); + await expect(item).toHaveCSS("transform", "none"); + await expect(item).toHaveCSS("filter", "none"); + } + + await expect(checkIn).toBeEnabled(); + await page.reload(); + await expect(items).toHaveCount(4); + await expect(prd(page).locator(".arrive")).toHaveCount(0); + }); + } + test("See it brings the section into view; nothing else scrolls the PRD", async ({ page, }) => { diff --git a/tests/unit/arrival.test.tsx b/tests/unit/arrival.test.tsx new file mode 100644 index 0000000..692b318 --- /dev/null +++ b/tests/unit/arrival.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useArrival } from "@/components/arrival"; + +// What a component shows arrives (`.arrive`) unless it was showing when the +// component mounted and has stayed since (slice 25). + +function Notice({ shown }: { shown: string | boolean | undefined }) { + const arrives = useArrival(shown); + if (!shown) return null; + return

    {String(shown)}

    ; +} + +function notice() { + return screen.getByRole("paragraph"); +} + +describe("useArrival", () => { + it("keeps still what showed as the component mounted, while it stays", () => { + const { rerender } = render(); + expect(notice()).not.toHaveClass("arrive"); + + rerender(); + expect(notice()).not.toHaveClass("arrive"); + }); + + it("brings in what appears after the component mounted", () => { + const { rerender } = render(); + + rerender(); + expect(notice()).toHaveClass("arrive"); + }); + + it("brings in what showed at first once it has gone and come back", () => { + const { rerender } = render(); + rerender(); + + rerender(); + expect(notice()).toHaveClass("arrive"); + }); + + it("brings in what shows under a new name, as when it moves", () => { + const { rerender } = render(); + expect(notice()).not.toHaveClass("arrive"); + + rerender(); + expect(notice()).toHaveClass("arrive"); + }); +}); diff --git a/tests/unit/conversation.test.tsx b/tests/unit/conversation.test.tsx index 12d1bef..6ff6ce1 100644 --- a/tests/unit/conversation.test.tsx +++ b/tests/unit/conversation.test.tsx @@ -780,4 +780,205 @@ describe("Conversation", () => { expect(container.querySelector("li span:not(.sr-only)")).toBeNull(); }); }); + + // What comes in after the chat showed arrives (`.arrive` in globals.css, + // slice 25). jsdom runs no animation, so these check which elements carry + // the classes; tests/e2e/chat.spec.ts checks that they move. + describe("what arrives", () => { + const followUp: ChatMessage = { + id: "visitor-2", + role: "user", + parts: [{ type: "text", text: "And Daan?" }], + }; + const GOALS: CheckedSection = { + section: "Goals / Objectives", + statements: [{ text: "Fewer double purchases.", citations: [] }], + }; + + function save( + state: SaveSectionPart["state"], + extra: Partial = {}, + ): SaveSectionPart { + return { + type: "tool-saveSection", + toolCallId: "save-1", + state, + input: { section: "Goals / Objectives", statements: [] }, + ...extra, + } as SaveSectionPart; + } + + function reply(...parts: ChatMessage["parts"]): ChatMessage { + return { id: "assistant-2", role: "assistant", parts }; + } + + /** Shows the conversation, then these messages after it. */ + function arrive( + messages: ChatMessage[], + status: ChatStatus, + stopped?: ReadonlySet, + ) { + const shown = show([question], "submitted"); + shown.rerender( + , + ); + return shown; + } + + it("brings in the visitor's new message from its corner, and each line of the answer as it appears", () => { + const before = [question, answer({ type: "text", text: "Henk." })]; + const { rerender } = show(before); + expect(document.querySelector(".arrive")).toBeNull(); + + rerender( + , + ); + expect(screen.getByText("And Daan?").closest("li")).toHaveClass( + "arrive", + "arrive-message", + ); + + rerender( + , + ); + expect(stepLine("Thought 1s")).toHaveClass("arrive"); + expect(stepLine("Search “notifications for new items”…")).toHaveClass( + "arrive", + "arrive-search", + ); + expect( + screen.getByText("Who wants pings?").closest("li"), + ).not.toHaveClass("arrive"); + }); + + it("keeps still everything in the messages it showed with, as after a reload, its ticks drawn", () => { + const { container } = show([ + question, + answer( + thought(1200), + search("output-available", { output: { passages: [HENK] } }), + save("output-available", { output: GOALS }), + { type: "text", text: "Saved." }, + ), + ]); + + expect(container.querySelector(".arrive")).toBeNull(); + const line = stepLine(/^Saved Goals \/ Objectives: no quotes/); + expect(line.querySelector("svg circle")).toBeInTheDocument(); + expect(line.querySelector(".tick-draws")).toBeNull(); + }); + + it("brings in a saved section's line anew once its check ends, its tick drawing itself", () => { + const { rerender } = arrive( + [question, reply(save("input-available"))], + "streaming", + ); + const checking = stepLine("Checking the quotes in Goals / Objectives…"); + expect(checking).toHaveClass("arrive"); + + rerender( + , + ); + const line = stepLine(/^Saved Goals \/ Objectives: no quotes/); + expect(line).not.toBe(checking); + expect(line).toHaveClass("arrive"); + const tick = line.querySelector(".tick-draws"); + expect(tick).toHaveAttribute("aria-hidden", "true"); + expect(tick?.querySelectorAll("circle, path")).toHaveLength(2); + }); + + it("keeps a search's line as it finishes, and its check's line as it fails or is stopped only as a new line", () => { + const { rerender } = arrive( + [question, reply(search("input-available"))], + "streaming", + ); + const running = stepLine("Search “notifications for new items”…"); + + rerender( + , + ); + expect( + stepLine("Search “notifications for new items” · passages from Henk"), + ).toBe(running); + + rerender( + , + ); + const checking = stepLine("Checking the quotes in Goals / Objectives…"); + rerender( + , + ); + const stopped = stepLine( + "Stopped checking the quotes in Goals / Objectives", + ); + expect(stopped).not.toBe(checking); + expect(stopped).toHaveClass("arrive"); + }); + + it("brings in a step that didn't work and the notices of an answer that broke off or was stopped", () => { + arrive( + [ + question, + reply({ + type: "dynamic-tool", + toolName: "saveSection", + toolCallId: "save-1", + state: "output-error", + input: {}, + errorText: "It wasn't in the form the check reads.", + }), + ], + "error", + ); + expect(screen.getByText(/^Saving the section didn't work\./)).toHaveClass( + "arrive", + ); + expect( + screen.getByText("This answer stopped before it finished."), + ).toHaveClass("arrive"); + }); + + it("brings in You stopped this answer, under the question too, but not under one it showed with", () => { + const { rerender } = show([question], "ready"); + rerender( + , + ); + + const [shownBefore, arrived] = screen.getAllByText( + "You stopped this answer.", + ); + expect(shownBefore).not.toHaveClass("arrive"); + expect(arrived).toHaveClass("arrive"); + }); + }); }); diff --git a/tests/unit/kept-conversation.test.tsx b/tests/unit/kept-conversation.test.tsx index 2b5269e..69ef1c1 100644 --- a/tests/unit/kept-conversation.test.tsx +++ b/tests/unit/kept-conversation.test.tsx @@ -311,6 +311,26 @@ describe("the home page after a reload", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("brings everything back still: the chat and the PRD, the conversation, the failure notice, the suggestion, the check-in and the download", () => { + keepConversation({ messages: DRAFTED, interrupted: true, stopped: [] }); + render(); + + expect( + screen.getByRole("button", { name: "Try again" }).parentElement, + ).toHaveRole("alert"); + expect( + screen.getByRole("group", { name: /try to break it/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^Looks right/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Download as Markdown" }), + ).toBeInTheDocument(); + expect(document.querySelector(".arrive")).toBeNull(); + expect(document.querySelector(".tick-draws")).toBeNull(); + }); + it("doesn't light up the sections it brings back, nor roll the count", () => { keepConversation({ messages: DRAFTED, interrupted: false, stopped: [] }); render(); diff --git a/tests/unit/prd-document.test.tsx b/tests/unit/prd-document.test.tsx index 64d66b1..38a4b62 100644 --- a/tests/unit/prd-document.test.tsx +++ b/tests/unit/prd-document.test.tsx @@ -1,4 +1,11 @@ -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { COPY_OUTCOME_MS } from "@/features/prd/copy-section"; import { PrdDocument, type CheckIn } from "@/features/prd/prd-document"; @@ -355,6 +362,29 @@ describe("the PRD", () => { screen.getByText("That answer didn't finish. Try again."), ).toBeVisible(); }); + + it("arrives under a section saved after the PRD showed, and when it comes back, but not as the PRD showed with it", () => { + const GOALS: CheckedSection = { + section: "Goals / Objectives", + statements: [{ text: "Fewer double purchases.", citations: [] }], + }; + const block = () => + screen.getByRole("button", { name: /^Looks right/ }).parentElement; + const { rerender } = showPrd({ checkIn: checkIn() }); + expect(block()).not.toHaveClass("arrive"); + + const next = checkIn({ next: "Target Audience / User Personas" }); + const both = sectionsOf(INTRODUCTION, GOALS); + rerender(prdOf({ sections: both, checkIn: next })); + expect(block()).toHaveClass("arrive", "arrive-block"); + expect(block()?.closest("section")?.getAttribute("aria-labelledby")).toBe( + "prd-goals-objectives", + ); + + rerender(prdOf({ sections: both, checkIn: { ...next, drafting: true } })); + rerender(prdOf({ sections: both, checkIn: { ...next, failed: true } })); + expect(block()).toHaveClass("arrive"); + }); }); describe("what just changed", () => { @@ -413,6 +443,67 @@ describe("the PRD", () => { expect(highlight("Goals / Objectives")).not.toBe(first); }); + it("brings in a section's statements one after another once it's saved, but not those of the sections it opened with", () => { + const { rerender } = showPrd(); + expect(document.querySelector(".arrive")).toBeNull(); + + const TWO: CheckedSection = { + ...GOALS, + statements: [ + ...GOALS.statements, + { text: "Planning takes five minutes.", citations: [] }, + ], + }; + rerender(prdOf({ sections: sectionsOf(INTRODUCTION, TWO) })); + + const [first, second] = statements("Goals / Objectives"); + expect(first).toHaveClass("arrive", "arrive-block"); + expect(first!.style.getPropertyValue("--order")).toBe("0"); + expect(second!.style.getPropertyValue("--order")).toBe("1"); + for (const statement of statements("Introduction / Overview")) { + expect(statement).not.toHaveClass("arrive"); + } + }); + + it("brings them in again with each change, without remounting what may have focus, and not when nothing changed", () => { + const { rerender } = showPrd({ sections: sectionsOf() }); + rerender(prdOf({ sections: sectionsOf(GOALS) })); + const [statement] = statements("Goals / Objectives"); + expect(statement).toHaveClass("arrive"); + expect(statement).not.toHaveAttribute("data-again"); + + const changed = (text: string): CheckedSection => ({ + ...GOALS, + statements: [{ text, citations: [] }], + }); + rerender(prdOf({ sections: sectionsOf(changed("No double buys.")) })); + expect(statements("Goals / Objectives")[0]).toBe(statement); + expect(statement).toHaveAttribute("data-again"); + + rerender( + prdOf({ + sections: sectionsOf(structuredClone(changed("No double buys."))), + }), + ); + expect(statement).toHaveAttribute("data-again"); + + rerender(prdOf({ sections: sectionsOf(changed("None at all.")) })); + expect(statements("Goals / Objectives")[0]).toBe(statement); + expect(statement).not.toHaveAttribute("data-again"); + }); + + it("brings in the download once a section is saved, but not as the PRD showed with it", () => { + const download = () => + screen.getByRole("button", { name: "Download as Markdown" }); + const { rerender } = showPrd({ sections: sectionsOf() }); + rerender(prdOf({ sections: sectionsOf(GOALS) })); + expect(download()).toHaveClass("arrive"); + + cleanup(); + showPrd(); + expect(download()).not.toHaveClass("arrive"); + }); + it("lights up a section saved again after Start over cleared it", () => { const { rerender } = showPrd(); rerender(prdOf({ sections: sectionsOf() })); diff --git a/tests/unit/rich-text.test.tsx b/tests/unit/rich-text.test.tsx index 141f4ea..818784a 100644 --- a/tests/unit/rich-text.test.tsx +++ b/tests/unit/rich-text.test.tsx @@ -292,6 +292,23 @@ describe("RichText as it arrives", () => { expect(now).not.toContain("Who needs"); }); + it("brings in a table's box as a block once the table takes shape, but not a table that was there before", () => { + const table = "| Persona | Needs |\n|---|---|\n| Maya | Lists |"; + const { container, rerender } = render( + , + ); + expect(container.querySelector(".arrive")).toBeNull(); + + rerender(); + expect(within(container).getByRole("table").parentElement).toHaveClass( + "arrive", + "arrive-block", + ); + + const before = render(); + expect(before.container.querySelector(".arrive")).toBeNull(); + }); + it("fades in inline code as one", () => { const { container, rerender } = render(); const before = spans(container); diff --git a/tests/unit/workspace.test.tsx b/tests/unit/workspace.test.tsx index ac31ecd..3b7334d 100644 --- a/tests/unit/workspace.test.tsx +++ b/tests/unit/workspace.test.tsx @@ -720,9 +720,8 @@ describe("Jump to latest", () => { * `scrollTo` only records the call. */ function sizePane() { - const pane = screen.getByRole("list", { - name: "Conversation", - }).parentElement!; + const conversation = screen.getByRole("list", { name: "Conversation" }); + const pane = conversation.parentElement!; let top = 700; Object.defineProperties(pane, { scrollHeight: { configurable: true, value: 1000 }, @@ -733,6 +732,11 @@ describe("Jump to latest", () => { set: (value: number) => (top = Math.min(value, 700)), }, }); + // The chat follows the conversation's end as laid out. + Object.defineProperties(conversation, { + offsetTop: { configurable: true, value: 0 }, + offsetHeight: { configurable: true, value: 1000 }, + }); const scrollTo = vi.fn(); pane.scrollTo = scrollTo; return { @@ -824,6 +828,17 @@ describe("Jump to latest", () => { expect(jumpButton()).not.toBeInTheDocument(); }); + it("arrives each time it shows", async () => { + await chatWithAnAnswer(); + const { scrollPaneTo } = sizePane(); + + scrollPaneTo(200); + expect(jumpButton()).toHaveClass("arrive"); + scrollPaneTo(660); + scrollPaneTo(100); + expect(jumpButton()).toHaveClass("arrive"); + }); + it("scrolls smoothly to the end, goes, hands focus to the message box, and the chat follows the answer on its way", async () => { reduceMotion(false); await chatWithAnAnswer(); @@ -1098,6 +1113,77 @@ describe("the home page when the demo's AI can't answer", () => { }); }); +// What appears after the page has shown arrives (`.arrive` in globals.css, +// slice 25). jsdom runs no animation, so these check which elements carry the +// classes; tests/e2e/intro.spec.ts checks that they move. +describe("what arrives in the workspace", () => { + /** The pane that holds a region. */ + function pane(name: string) { + return screen.getByRole("region", { name }).parentElement!; + } + + it("brings in the chat and the PRD when the intro gives way to them, the PRD a beat later", () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: "Or ask your own question first" }), + ); + + const chat = pane("Chat with the assistant"); + const prd = pane("Shared grocery list app"); + expect(chat).toHaveClass("arrive", "arrive-pane"); + expect(chat.style.getPropertyValue("--order")).toBe(""); + expect(prd).toHaveClass("arrive", "arrive-pane"); + expect(prd.style.getPropertyValue("--order")).toBe("1"); + }); + + it("brings in the suggestion, the check-in and the download once the first section is saved", async () => { + fetchMock.mockResolvedValueOnce(answer(SAVES_INTRODUCTION)); + render(); + fireEvent.click(draftButton()); + + expect( + await screen.findByRole("group", { name: /try to break it/ }), + ).toHaveClass("arrive", "arrive-block"); + expect( + screen.getByRole("button", { name: /^Looks right/ }).parentElement, + ).toHaveClass("arrive", "arrive-block"); + expect( + screen.getByRole("button", { name: "Download as Markdown" }), + ).toHaveClass("arrive"); + }); + + it("brings in the notice of an answer that failed, and the allocation's once it's used up", async () => { + fetchMock.mockResolvedValueOnce( + answer([{ type: "error", errorText: DEMO_BUSY }]), + ); + render(); + fireEvent.click( + screen.getByRole("button", { name: "Or ask your own question first" }), + ); + const ask = () => { + fireEvent.change(screen.getByRole("textbox", { name: "Your message" }), { + target: { value: "Who wants a ping?" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + }; + ask(); + const chat = within(pane("Chat with the assistant")); + expect(await chat.findByRole("alert")).toHaveClass( + "arrive", + "arrive-block", + ); + + fetchMock.mockResolvedValueOnce( + answer([{ type: "error", errorText: QUOTA_USED_UP }]), + ); + ask(); + expect( + await chat.findByText(/allocation for today is used up/), + ).toBeInTheDocument(); + expect(chat.getByRole("alert")).toHaveClass("arrive", "arrive-block"); + }); +}); + describe("when the allocation resets", () => { it("is the next 00:00 UTC, also late in the UTC day", () => { expect(nextReset(new Date("2026-10-20T06:22:00Z")).toISOString()).toBe(