From ac38ec6aa1b00394e227965f71f74437bf60740d Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Sun, 25 Oct 2026 11:39:36 +0100 Subject: [PATCH 1/4] Record slice 27's merge [skip ci] Co-Authored-By: Claude Opus 5.5 --- docs/plan.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 36bcac9..bb58604 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,15 +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, is done on `feat/chat-in-front` (`.claude/worktrees/slice-27-chat-front`), from `main` at 4e6ff45, waiting for the owner's look, push and merge. On a laptop the PRD is 36% of the page and at least 448px (448 at 1024px, 501 at 1440, 559 at 1920) and the chat the rest (496, 859, 961); the chat's text, its messages, the suggestion and the message box share one centred column of at most 46rem; the PRD's page has 24px of padding a side; a long section heading wraps its own words with its copy button on its first line, and the button's label ends where the button ends, since centred it made the narrower PRD scroll sideways. Phones stack as before. How it's built: `src/features/AGENTS.md`, *Desktop layout* and *The chat's column*. Local checks on 2026-10-23: `verify` (589 unit tests) and `test:e2e` (85, 2 new; the PRD and layout tests then passed three more runs each); three PRD tests had pinned the old geometry (the pop-over always opening under its name, the check-in in view without scrolling) and now check the same requirement in either placement. The page was checked in the Browser pane with the replayed answers at 1024, 1440 and 1920px, and stacked at 1000 and 375. It changes no file in `src/server` or `src/lib`. -- **Also done:** on `feat/chat-in-front` too, the owner's choice (2026-10-25): one branch and one pull request for both. A search or quote check that arrives while it works no longer rises in again every 0.4s (its arrival and its moving band had mixed into one endless animation, with no band), and "Drafting…" stays beside a section whose save failed while the assistant tries again. Found by the owner on 2026-10-25 with the database down. The tour's error and retry steps (6, 8, 12 to 16, each with "Try again") then showed no other endless animation. Local checks on 2026-10-25: `verify` (590 unit tests, 1 new) and `test:e2e` (85, 2 new). It changes no file in `src/server` or `src/lib`. -- **Also on it** (the owner, 2026-10-25): "Used 3 searches" no longer loses its number while the count rolls during a running search, and a reply is typed out as opencode shows a streaming one, about 600 characters a second and all of it within about 2.5 seconds, each step's words fading in over 0.18s (`docs/decisions.md`, Design, 2026-10-25; `src/features/AGENTS.md`, *A reply is typed out*). The long recorded reply typed out on a production build with no long animation frame. A chat that follows its end keeps following while a reply types on after its answer has ended. Local checks on 2026-10-25: `verify` (589 unit tests) and `test:e2e` (88, 1 new); the layout tests passed three more runs. One PRD test now waits for the chat to reach its end instead of checking it at one moment, since the reply types on below the statements. An answer keeps one "Thinking" line through its steps, out of sight while a step runs, so it no longer blinks and restarts its band at each step, and a reply shows its first words as it arrives, taking the line's place in the same frame. A count now glides to its new number, half a line as it fades, instead of a full line's slide in a clipped box. A local build kept the old stylesheet until `.next` was deleted (KI-11). +- **Doing:** nothing. Slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11). - **The README's demo** (merged, a4d5574): `docs/media/prd-assistant-demo.gif` and its source copy `prd-assistant-demo.mp4`, a motion piece about the interface made in Remotion 4.0.538 in its own project, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository; its copy, colours and timings are in that project's `src/config.ts`. `npx remotion render PrdAssistantDemo out/prd-assistant-demo.mp4 --codec=h264 --crf=16 --image-format=png`, and for the GIF `--codec=gif --every-nth-frame=2 --scale=0.5`. The GIF's click opens the MP4 through GitHub's own video hosting. - **Next:** - 1. Once `feat/chat-in-front` is merged: its commit id here and in slice 27. + 1. The owner's verdict on the interface as it is on `main` now, and any changes it brings. 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. +- **Waiting on the owner:** a verdict on the interface on `main`; 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. @@ -120,10 +118,4 @@ ### 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: 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). - - The chat's text keeps a comfortable reading width however wide the chat gets (about 46rem, near opencode's 744px), so its lines don't run long on a wide screen, and nothing beside it looks stranded. - - The PRD stays comfortable to read in its narrower column, its page's padding trimmed to fit: no statement squeezed to a few words a line, the title block, the tally, the copy button and the pop-over all whole, down to a 1024px laptop, where the chat stays usable too. - - Phones keep the stacked layout. The layout tests measure the new shares (`tests/e2e/layout.spec.ts` pins today's), and the page is checked at 1024, 1440 and 1920px. +### 27 · The chat in front, the PRD beside it · risk: normal · status: done (b5ba6c0, with fixes 28ab6ec to 07b17c0) From 3490e60229958ab22e55f0496d91e64520ae13a1 Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Mon, 26 Oct 2026 10:04:15 +0100 Subject: [PATCH 2/4] Collect the owner's interface review so far [skip ci] Co-Authored-By: Claude Opus 5.5 --- docs/plan.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/plan.md b/docs/plan.md index bb58604..a91f124 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -119,3 +119,11 @@ ### 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: done (b5ba6c0, with fixes 28ab6ec to 07b17c0) + +### 28 · The owner's interface review · risk: normal · status: being collected (the owner, 2026-10-25, one item at a time; build once the list is complete) +- **Goal:** the interface as calm and considered as the owner's example, https://www.oualidfaouzi.dev/ (read 2026-10-25), without changing what it does. +- **Acceptance so far:** + - "Start over" asks in a small pop-over under its button, the app's own pop-over in look and behaviour ("Clear the chat and the PRD?", "Clear both", "Keep them"; Escape or a click outside closes it, focus goes into it and back), and the row above the PRD never changes or wraps. Today its question and two buttons join "Download as Markdown" in that row and wrap under it in the PRD's column. + - The example's neutral palette replaces pure black and white: background #0a0a0a, panels #171717 (the PRD's page, pop-overs), lines #262626; headings, bold words, names and buttons #fafafa; the chat's replies and the PRD's statements #d4d4d4 (the owner's choice over the example's #a1a1a1 for long reading; one value to change); captions, step lines, tallies and other secondary text #a1a1a1. No colour accents, no cream. Every text tone passes the accessibility check's contrast. + - The big headings lighter and slightly tighter, as the example's are (its name is weight 300 with tightened tracking): the PRD's title and the section headings, in Inter, which stays (the example uses each device's system font, which would look different on every computer). + - The first screen feels considered rather than rushed, and light (the owner, 2026-10-25, after two studies of other first screens: product pages, then genuine project demos): a centred composition with generous space instead of a narrow column on the left; the heading, larger and lighter, still casting the visitor's role; one short line under it, what they'll do and that the app checks every quote word for word, the longer explanation staying behind "How the search and the check work"; the households as one quiet line naming Maya, Henk, Daan, Sofia, Lena and Ravi, linking to the interviews; the button and the quiet ways in ("Read the full interviews", "Or ask your own question first") as today; and one quiet visual, a small, softened glimpse of the app itself, the chat beside the PRD, not readable PRD text, so the visitor sees where they're headed (under the text or left out on a phone). It arrives smoothly, line by line, the glimpse last; reduced motion shows it at once. It stays on one screen, without product-page parts: no benefit headline, logos, stats or long scroll. From 48a87013dc0d9162ead5722c8b4ffc3f9203eb4f Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Mon, 26 Oct 2026 10:05:08 +0100 Subject: [PATCH 3/4] Mark the owner's interface review ready to build [skip ci] Co-Authored-By: Claude Opus 5.5 --- docs/plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index a91f124..281f501 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -23,10 +23,10 @@ - **Doing:** nothing. Slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11). - **The README's demo** (merged, a4d5574): `docs/media/prd-assistant-demo.gif` and its source copy `prd-assistant-demo.mp4`, a motion piece about the interface made in Remotion 4.0.538 in its own project, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository; its copy, colours and timings are in that project's `src/config.ts`. `npx remotion render PrdAssistantDemo out/prd-assistant-demo.mp4 --codec=h264 --crf=16 --image-format=png`, and for the GIF `--codec=gif --every-nth-frame=2 --scale=0.5`. The GIF's click opens the MP4 through GitHub's own video hosting. - **Next:** - 1. The owner's verdict on the interface as it is on `main` now, and any changes it brings. + 1. Slice 28, the owner's interface review, on its own branch from `main` (with this branch's `docs/record-slice-27-merge` notes as its first commits); one session. Its decisions go into `docs/decisions.md` as it's built: the palette replaces the black-and-white rule of 2026-10-17, which keeps its ban on monospace labels (the owner, 2026-10-25). 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 verdict on the interface on `main`; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. +- **Waiting on the owner:** a look at slice 28 once it's built; 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. @@ -120,7 +120,7 @@ ### 27 · The chat in front, the PRD beside it · risk: normal · status: done (b5ba6c0, with fixes 28ab6ec to 07b17c0) -### 28 · The owner's interface review · risk: normal · status: being collected (the owner, 2026-10-25, one item at a time; build once the list is complete) +### 28 · The owner's interface review · risk: normal · status: todo (collected from the owner, 2026-10-25, one item at a time; complete on 2026-10-26) - **Goal:** the interface as calm and considered as the owner's example, https://www.oualidfaouzi.dev/ (read 2026-10-25), without changing what it does. - **Acceptance so far:** - "Start over" asks in a small pop-over under its button, the app's own pop-over in look and behaviour ("Clear the chat and the PRD?", "Clear both", "Keep them"; Escape or a click outside closes it, focus goes into it and back), and the row above the PRD never changes or wraps. Today its question and two buttons join "Download as Markdown" in that row and wrap under it in the PRD's column. From b9da71480e9dcf87df6b29877c200c5fbbba463e Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Mon, 26 Oct 2026 10:39:26 +0100 Subject: [PATCH 4/4] Ask before Start over in a pop-over, take the owner's neutral palette and centre the first screen Co-Authored-By: Claude Opus 5.5 --- docs/decisions.md | 6 +- docs/plan.md | 11 +- docs/product/prd.md | 2 +- src/app/globals.css | 108 +++++++---- src/app/icon.svg | 4 +- src/components/popover.ts | 24 +++ src/components/ui/button.tsx | 2 +- src/features/AGENTS.md | 10 +- src/features/chat/conversation.tsx | 5 +- src/features/chat/rich-text.tsx | 7 +- src/features/prd/prd-document.tsx | 50 ++--- src/features/prd/source-popover.tsx | 25 +-- src/features/prd/start-over.tsx | 80 ++++++++ src/features/workspace/intro.tsx | 271 +++++++++++++++++++++++++--- tests/AGENTS.md | 1 + tests/e2e/chat.spec.ts | 4 +- tests/e2e/intro.spec.ts | 115 +++++++++++- tests/e2e/prd.spec.ts | 41 ++++- tests/unit/prd-document.test.tsx | 18 +- tests/unit/setup.ts | 55 ++++++ tests/unit/workspace.test.tsx | 21 ++- 21 files changed, 705 insertions(+), 155 deletions(-) create mode 100644 src/components/popover.ts create mode 100644 src/features/prd/start-over.tsx diff --git a/docs/decisions.md b/docs/decisions.md index a0ad1e2..ee46660 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3,6 +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-26 · The owner's interface review, slice 28 (the owner, 2026-10-25 and 26: as calm and considered as their own site, https://www.oualidfaouzi.dev/, without changing what the app does): its neutral palette, background #0a0a0a, panels #171717 (the PRD's page, the pop-overs), lines #262626, also the visitor's messages; headings, bold words, names and buttons #fafafa; the chat's replies and the PRD's statements #d4d4d4 (the owner's choice over the site's #a1a1a1, for long reading; one value, `--prose`); captions, step lines, tallies and other secondary text #a1a1a1; every text tone passes the accessibility check's contrast; a control's edge (the outline buttons, the message box, a pop-over) #404040, brightening to the grey on hover as the site's buttons do (the agent's call: the site's #262626 hardly showed the message box); no colour accents. Not allowed, as before: cream or off-white backgrounds, italic accent words in headlines, numbered "01/02/03" section labels, monospace labels, pill-shaped buttons. The PRD's title in weight 300 and its section headings in 400, slightly tighter, in Inter, which stays (the site uses each device's system font). "Start over" asks in a small pop-over under its button, the source's pop-over in look and behaviour: "Clear the chat and the PRD?", "Clear both", "Keep them", focus on "Keep them" as it opens and back on the button as it closes, closed by Escape or a click outside, and the row above the PRD never changes or wraps. The first screen centred on one screen with room around it: the heading larger and lighter, still casting the visitor's role; one short line, what they'll do and that the app checks every quote word for word, the longer explanation staying behind the footer's "How the search and the check work"; one quiet line "Interviewed: Maya, Henk, Daan, Sofia, Lena and Ravi", each name linking to its interview; the button and the quiet ways in as before; and under them a small, softened glimpse of the chat beside the PRD, bars for text, left out on a phone; it arrives line by line as the page first shows, the glimpse last, once (not again as the browser takes over the server's page, and not when the intro comes back); reduced motion shows it at once. No product-page parts: no benefit headline, logos, stats or long scroll · Replaces the black-and-white colour scheme of 2026-10-17 (black dominant, white text, lines and accents), the PRD page's #141414 and white statements (2026-10-18), the intro's two paragraphs in a column on the left (2026-10-19), and "Start over"'s question in the row above the PRD, which wrapped under "Download as Markdown" in the PRD's column - 2026-10-25 · A reply is typed out as opencode shows a streaming one (the owner, 2026-10-25: a large text "pops in" and feels odd; "look at how opencode types or renders in AI text responses"; opencode's `createPacedValue` in `packages/session-ui/src/message/message-content.tsx` and its `markdown-word-enter` in `packages/session-ui/src/components/markdown.css`, at acdcdb8, read 2026-10-25): what shows of a reply grows from the top a few words every 24ms, each step ending after a word, at about 600 characters a second and faster for a long reply, so it's all shown within about 2.5 seconds of arriving (the owner's choice over a real model's slower pace and over opencode's own rules, which show anything under 512 characters at once and only smooth a reply that streams); the words each step adds fade in together over 180ms, opacity only, as opencode's do (the owner's choice over the video's rise and blur); its formatting is in place as it types, never a half-typed mark; reduced motion shows it at once · Replaces the words of 2026-10-22, flowing in over at most about half a second, rising from a slight blur, every paragraph at once, which made a long reply flash in everywhere at the same moment - 2026-10-22 · The chat is the main thing on the screen and the PRD supports it beside it (the owner, 2026-10-22: the PRD preview "takes up too much space in the web app. The chat should be the main thing and the preview should be supportive, yet not too congested or tight either", and it's "definitely way too large now"): on a laptop the PRD gets a supporting column and the chat the rest, its text kept to a comfortable reading width: on a 1440px laptop the PRD about 500px and the chat the rest (the owner, 2026-10-22), built as slice 27 after the motion of slice 26; as built, the PRD is 36% of the page and at least 448px, where its text keeps about 45 characters a line, so on a 1024px laptop the PRD 448px and the chat 496, and the chat's text, its messages and its message box share a centred column of at most 46rem (the agent's call) · Replaces a chat column of steady width beside a PRD that takes the rest (2026-10-20), which on a 1440px laptop gave the PRD about 840px and the chat 520 - 2026-10-22 · The app's motion follows the README's demo video as closely as it can (the owner, 2026-10-22: "bring it as close as possible"; the video's project is `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository, its motion in `src/Showcase.tsx` and `src/ui/motion.ts`): one shared set of curves, the video's springs replayed in CSS with `linear()` (the owner left the build to whatever comes closest, and for one-way entrances CSS plays the same curve, so no library); what appears arrives, rising a few pixels, sharpening from a slight blur and settling; words flow in one after another even when a reply arrives whole, over at most about half a second, and words already shown never move again; searches in a row fold together with a short collapse, and a saved section's line gets a tick that draws itself, both departing from opencode's search form (2026-10-20) by the owner's choice; the pop-over opens and closes with a scale and fade, and its quote's highlight sweeps in. Reduced motion still turns all of it off, and nothing waits for an animation · Slices 25 and 26. The video looked smoother than the app: there, most things just appear, a reply that arrives whole fades in as one block in 0.18s, and five different timings are in use @@ -17,11 +18,10 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque - 2026-10-18 · The PRD beside the chat, after the owner's review of the second mockup against a competitor's product: the split, the one-line steps, the pop-over with the researcher's question above the answer, and the copy stay; the PRD pane is a document, not cards, on a slightly lighter near-black page with no border (`#141414` on the pane's black), at most 42rem of text wide; it opens with a title block: the product's name at about 26px, one grey line "Product requirements document · Draft · 2 of 9 sections · From six household interviews", a hairline and the explanation; all nine sections show from the start as an outline, the ones not drafted yet as grey headings; a drafted section has an 18px heading with its tally ("11 of 12 quotes confirmed") in grey beside it, then its statements as a bulleted list; sizes set once: statements 16px white, quoted words 15–16px, captions 13–14px grey, step lines 14px, nothing smaller than 13px; gaps: 2–4px from a statement to what's written under it, 10px between list items, 32px above each section heading and 10px below it; above the page the pane holds only "Start over"; what the code says has one style, plain white 500-weight lines at caption size, and nothing in the pane has a border at rest except the one button; the pop-over is at most about 520px wide and anchored to what was clicked on desktop, with its anchor rules inside `@supports` so browsers without anchor positioning (iPhones before iOS 26 among them) get a usable centred box; on phones it isn't anchored: nearly full width, scrolling inside, closed by a tap outside; the PRD pane never scrolls by itself; the "Saved" line in the chat ends in "See it", which scrolls to that section, in the pane on a laptop or down the page on a phone, and no longer opens an earlier version; the search lines as decided on 2026-10-20; the chat follows the newest message unless the visitor has scrolled up; the message box uses 16px text, so iOS doesn't zoom; phones stack, with no tabs · Replaces the first two proposals: section cards that fold, a boxed explanation and labels as chips. The owner's final changes, after the third mockup still read as loose sentences rather than a document: the title block, the outline, bulleted statements with the tighter gaps, and the page (they replace the pane header "Your PRD · 1 of 9 sections", a hairline between sections and 20–24px between statements). The check-in button's states and the rest are in `docs/plan.md`, slice 6 - 2026-10-18 · Three fixes in code, never in the prompt, built with slice 6: any statement whose only quotes are the visitor's own chat messages shows as the visitor's decision, not from the interviews, whatever the model wrote; under each saved section a confirm button does the check-in; Markdown marks such as `**` in the model's text are rendered or stripped, never shown as typed · On staging (2026-10-18), gpt-oss-120b wrote "Some households expressed interest" for the visitor's grocery delivery claim, ended a draft without checking in, and showed `**` as typed - 2026-10-19 · The transcripts page stays as it is, the plain list from slice 1, and the intro's quieter link, "Read the full interviews", leads there; the intro makes sense on its own, and nothing a reviewer needs is only on the transcripts page (the owner, 2026-10-19) · Settles the open question of slice 7 and the PRD's Design Considerations: quotes open in the pop-over, and the full interviews stay one click away, from the intro and from the header's "Transcripts" -- 2026-10-19 · The intro, as built with slice 7 inside the decided design: the heading "You're the product manager of a shared grocery list app", two short paragraphs, the filled white button "Draft the first section", which sends `FIRST_SECTION_REQUEST` from `src/lib/suggestions.ts`, and under it, quieter, the link "Read the full interviews" and the control "Or ask your own question first", which opens the empty chat without sending anything, focus in the message box; the chat and the PRD show from then on, and whenever the conversation holds messages (`conversationStarted` in `src/features/workspace/disclosure.ts`, for slice 10 to reuse); when the intro's request fails before a section is saved, the intro comes back with "The first section wasn't drafted: something went wrong before the assistant could finish. Try again in a moment." above its button, the conversation stays empty, and the message box keeps what the visitor typed meanwhile; once a section is saved, a failure stays in the chat with "Try again", like any answer that broke off, and the PRD keeps the section; the link for engineers sits in every page's footer, "How the search and the check work" (the README's `#how-it-works`) beside "Source code on GitHub", so the intro and the chat both have it; once a section is saved, the suggestion sits above the message box, "Suggestion:" (since 2026-10-22, below) over an outline button with `GROCERY_DELIVERY_SUGGESTION`'s words, which sends them in one click, moves focus to the message box, waits while the assistant works and is gone once the conversation holds that message; focus moves to the chat's heading for screen readers, "The chat and your PRD", after the button, and to the intro's heading after "Start over" or a failed first request · A section the check already passed is never thrown away, nothing the visitor typed is lost, and focus never falls to the page. The owner decided on 2026-10-20 to keep "Or ask your own question first", built with slice 7 as a proposal: it keeps a question-first visit possible, as the chat's placeholder invites, and it is how the end-to-end tests reach an empty chat (`openChat` in `tests/e2e/chat.spec.ts` and `tests/e2e/prd.spec.ts`) +- 2026-10-19 · The intro, as built with slice 7 inside the decided design: the heading "You're the product manager of a shared grocery list app", its words under it (since 2026-10-26, above), the filled button "Draft the first section", which sends `FIRST_SECTION_REQUEST` from `src/lib/suggestions.ts`, and under it, quieter, the link "Read the full interviews" and the control "Or ask your own question first", which opens the empty chat without sending anything, focus in the message box; the chat and the PRD show from then on, and whenever the conversation holds messages (`conversationStarted` in `src/features/workspace/disclosure.ts`, for slice 10 to reuse); when the intro's request fails before a section is saved, the intro comes back with "The first section wasn't drafted: something went wrong before the assistant could finish. Try again in a moment." above its button, the conversation stays empty, and the message box keeps what the visitor typed meanwhile; once a section is saved, a failure stays in the chat with "Try again", like any answer that broke off, and the PRD keeps the section; the link for engineers sits in every page's footer, "How the search and the check work" (the README's `#how-it-works`) beside "Source code on GitHub", so the intro and the chat both have it; once a section is saved, the suggestion sits above the message box, "Suggestion:" (since 2026-10-22, below) over an outline button with `GROCERY_DELIVERY_SUGGESTION`'s words, which sends them in one click, moves focus to the message box, waits while the assistant works and is gone once the conversation holds that message; focus moves to the chat's heading for screen readers, "The chat and your PRD", after the button, and to the intro's heading after "Start over" or a failed first request · A section the check already passed is never thrown away, nothing the visitor typed is lost, and focus never falls to the page. The owner decided on 2026-10-20 to keep "Or ask your own question first", built with slice 7 as a proposal: it keeps a question-first visit possible, as the chat's placeholder invites, and it is how the end-to-end tests reach an empty chat (`openChat` in `tests/e2e/chat.spec.ts` and `tests/e2e/prd.spec.ts`) - 2026-10-17 · Desktop first; on a phone the chat and the PRD stack on one page, usable not polished; the home page opens on a short intro screen, then shows the chat with the PRD beside it; quotes open their source in a pop-over, with the researcher's question above the answer; no separate UX document · The PRD's Design section is the design brief - 2026-10-17 · The demo explains itself through progressive disclosure: the intro screen makes the visitor the product manager, with the researcher's six interviews as finished records, and its one button drafts the first section; the AI drafts before asking and asks only what the transcripts don't say, recording the answer as the visitor's own decision; each step shows as one line (searches in a row folded: 2026-10-20); quotes show their source by name and the check's result, explained once next to the first section; a suggestion appears only after the first section, a plain request nothing in the interviews backs (adding grocery delivery, since 2026-10-22, below), while the claims that users asked for grocery delivery or a dark mode stay in the quality check; a quiet link leads engineers to the README and the code · Replaces the opening summary, the upfront gap questions and the suggested first messages; the quality check tests the new flow -- 2026-10-17 · Where the PRD and the plan name a placement or a control (the PRD beside the chat, a pop-over, one line per step, a line under the chat box, an explanation next to the first section, the transcripts page), they state the purpose, not a fixed layout; the piece of work that builds it may propose another form that serves the same purpose, and the owner decides before it's built; tests check the purpose (content, behaviour, accessibility), not the placement; the colour rules below stay fixed · Keeps the UI open until each piece is built; whether the transcripts page stays is decided with the intro screen -- 2026-10-17 · Black and white colour scheme: black dominant (main backgrounds and large surfaces), white in support (text, lines, accents). Not allowed: cream or off-white backgrounds, italic accent words in headlines, numbered "01/02/03" section labels, monospace labels, pill-shaped buttons · Feeds the PRD's Design Considerations and every UI piece +- 2026-10-17 · Where the PRD and the plan name a placement or a control (the PRD beside the chat, a pop-over, one line per step, a line under the chat box, an explanation next to the first section, the transcripts page), they state the purpose, not a fixed layout; the piece of work that builds it may propose another form that serves the same purpose, and the owner decides before it's built; tests check the purpose (content, behaviour, accessibility), not the placement; the colour rules stay fixed (2026-10-26, above) · Keeps the UI open until each piece is built; whether the transcripts page stays is decided with the intro screen - 2026-10-22 · After the first section the chat suggests a plain request, "Add grocery delivery to the PRD.", under "Suggestion:", instead of a challenge to break the PRD with a claim that users asked for it (the owner) · A suggestion should read as one; the challenge confused visitors. The measurement keeps the claim as its request D, so its records stay comparable ## Environments and accounts diff --git a/docs/plan.md b/docs/plan.md index 281f501..0d090cc 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,13 +20,14 @@ ### 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:** nothing. Slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11). +- **Doing:** slice 28, the owner's interface review, is done on `feat/interface-review` (`.claude/worktrees/slice-27-chat-front`), from `main` at 07b17c0 with the `docs/record-slice-27-merge` notes as its first commits, waiting for the owner's look, push and merge. "Start over" asks in a pop-over under its button; the neutral palette of the owner's site, with `--prose` for replies and statements; the PRD's headings lighter; the first screen centred, with the households linking to their interviews and a glimpse of the workspace, arriving line by line once. Local checks on 2026-10-26: `verify` (590 unit tests) and `test:e2e` (92, 4 new; the intro and PRD tests then passed three more runs each); the page was checked in the Browser pane with the replayed answers at 1440 and 1024px and on a phone. It changes no file in `src/server` or `src/lib`. How it's built: `src/features/AGENTS.md`, *"Start over" asks first*, *The palette* and *The intro*. +- **Before it:** slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11). - **The README's demo** (merged, a4d5574): `docs/media/prd-assistant-demo.gif` and its source copy `prd-assistant-demo.mp4`, a motion piece about the interface made in Remotion 4.0.538 in its own project, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository; its copy, colours and timings are in that project's `src/config.ts`. `npx remotion render PrdAssistantDemo out/prd-assistant-demo.mp4 --codec=h264 --crf=16 --image-format=png`, and for the GIF `--codec=gif --every-nth-frame=2 --scale=0.5`. The GIF's click opens the MP4 through GitHub's own video hosting. - **Next:** - 1. Slice 28, the owner's interface review, on its own branch from `main` (with this branch's `docs/record-slice-27-merge` notes as its first commits); one session. Its decisions go into `docs/decisions.md` as it's built: the palette replaces the black-and-white rule of 2026-10-17, which keeps its ban on monospace labels (the owner, 2026-10-25). + 1. Once `feat/interface-review` is merged: its commit id here and in slice 28. 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 slice 28 once it's built; 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; "Start over"'s pop-over under its button, on anchor positioning like the source's, else a centred box; the intro's arrival timed from the first paint, and its glimpse's fade at its foot; 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 slice 28 (`npm run dev:replay -- --port 3200` from the worktree: the first screen, then "Start over" once a section is saved), then pushing `feat/interface-review`, 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. @@ -120,7 +121,7 @@ ### 27 · The chat in front, the PRD beside it · risk: normal · status: done (b5ba6c0, with fixes 28ab6ec to 07b17c0) -### 28 · The owner's interface review · risk: normal · status: todo (collected from the owner, 2026-10-25, one item at a time; complete on 2026-10-26) +### 28 · The owner's interface review · risk: normal · status: done (on `feat/interface-review`; its id on `main` goes here after the merge) - **Goal:** the interface as calm and considered as the owner's example, https://www.oualidfaouzi.dev/ (read 2026-10-25), without changing what it does. - **Acceptance so far:** - "Start over" asks in a small pop-over under its button, the app's own pop-over in look and behaviour ("Clear the chat and the PRD?", "Clear both", "Keep them"; Escape or a click outside closes it, focus goes into it and back), and the row above the PRD never changes or wraps. Today its question and two buttons join "Download as Markdown" in that row and wrap under it in the PRD's column. diff --git a/docs/product/prd.md b/docs/product/prd.md index 29649f5..d4ede15 100644 --- a/docs/product/prd.md +++ b/docs/product/prd.md @@ -221,7 +221,7 @@ Product type: a web application built with Next.js, Supabase (Postgres with pgve - The placements and controls named in this document (the PRD beside the chat, the pop-over, one line per step, the line under the chat box, the explanation next to the first section) state what each part must do. The piece of work that builds one may propose another form that serves the same purpose, and the owner decides before it's built. Tests check the purpose, not the placement. The look below stays fixed. **Look** -- Black and white, with black dominant on backgrounds and large surfaces, and white in support for text, lines and accents. +- A neutral palette of near-black backgrounds and greys, with text in three tones and no colour accents (`docs/decisions.md`, Design, 2026-10-26). - Not allowed: cream or off-white backgrounds, italic accent words in headlines, numbered "01/02/03" section labels, monospace labels, pill-shaped buttons. **Mockups:** none. There's no separate design document; this section and the rules above are the design brief. diff --git a/src/app/globals.css b/src/app/globals.css index 6b91e48..c3fd796 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,20 +1,28 @@ @import "tailwindcss"; /* - * Design tokens (docs/decisions.md, Design): black dominant on backgrounds and - * large surfaces, white in support for text, lines and accents. No cream or + * Design tokens (docs/decisions.md, Design, 2026-10-26): the neutral palette + * of the owner's own site, near-black backgrounds, a lighter grey for panels + * and lines, and text in three tones. No colour accents, no cream or * off-white backgrounds, no pill shapes, no monospace labels. */ :root { - --background: #000000; - --surface: #0d0d0d; - --foreground: #ffffff; - /* 9.4:1 on black, well above WCAG AA for body text. */ - --muted-foreground: #b4b4b4; - --border: rgb(255 255 255 / 0.18); - --border-strong: rgb(255 255 255 / 0.5); - /* The PRD's page, slightly lighter than the pane around it. */ - --page: #141414; + --background: #0a0a0a; + /* Panels: the pop-overs and the PRD's page. */ + --surface: #171717; + --page: #171717; + /* Headings, bold words, names and buttons. */ + --foreground: #fafafa; + /* What's read at length: the chat's replies and the PRD's statements, the + owner's choice over the site's #a1a1a1. 13.4:1 on the background, 12.1:1 + on a panel. */ + --prose: #d4d4d4; + /* Captions, step lines, tallies: 7.7:1 on the background, 6.9:1 on a + panel, 5.9:1 in the visitor's bubble. */ + --muted-foreground: #a1a1a1; + --border: #262626; + /* A control's edge: the outline buttons, the message box, a pop-over. */ + --border-strong: #404040; /* The visitor's messages in the chat. */ --bubble: #262626; } @@ -91,6 +99,7 @@ --color-background: var(--background); --color-surface: var(--surface); --color-foreground: var(--foreground); + --color-prose: var(--prose); --color-muted-foreground: var(--muted-foreground); --color-border: var(--border); --color-border-strong: var(--border-strong); @@ -655,15 +664,11 @@ body { } /* - * The pop-over a source's name opens. On a phone it isn't anchored: nearly - * full width, scrolling inside, closed by a tap outside. On a laptop it sits - * by the name that opened it where the browser has anchor positioning, and is - * a centred box where it hasn't (iPhones before iOS 26 among them). + * The app's pop-overs, a source's quote and "Start over"'s question, share a + * look and their motion (`.popover`). Where the browser lacks anchor + * positioning (iPhones before iOS 26 among them), each is a centred box. */ -.source-popover { - width: calc(100vw - 1.5rem); - max-height: 80dvh; - overflow-y: auto; +.popover { margin: auto; padding: 1.25rem; border: 1px solid var(--border-strong); @@ -672,6 +677,39 @@ body { color: var(--foreground); } +/* + * "Start over"'s question, under its button and ending where it ends, on + * every screen. + */ +.start-over-popover { + width: max-content; + max-width: calc(100vw - 1.5rem); + padding: 1rem; +} + +@supports (position-area: bottom) { + .start-over-popover { + inset: auto; + margin: 0.375rem 0; + position-area: bottom span-left; + position-try-fallbacks: + bottom span-right, + top span-left, + top span-right; + } +} + +/* + * The pop-over a source's name opens. On a phone it isn't anchored: nearly + * full width, scrolling inside, closed by a tap outside. On a laptop it sits + * by the name that opened it. + */ +.source-popover { + width: calc(100vw - 1.5rem); + max-height: 80dvh; + overflow-y: auto; +} + .source-popover::backdrop { background: rgb(0 0 0 / 0.5); } @@ -705,25 +743,25 @@ body { 0 0 / 100% 100% no-repeat; } -/* Closing, it takes no clicks. */ -.source-popover:not(:popover-open) { +/* Closing, a pop-over takes no clicks. */ +.popover:not(:popover-open) { pointer-events: none; } /* - * The pop-over opens from the name that was clicked, as the video's does: it - * grows from 92% and fades in on the pop-over's spring, around the point - * nearest the name (`--grow-from`, from `prd/source-popover.tsx`), and closes - * the same way. Where the browser can't keep it on top as it closes, without - * `overlay` (Firefox and Safari, MDN's compatibility data, read 2026-10-22), - * it closes at once; without `@starting-style` (before Chrome 117, Firefox - * 129 and Safari 17.5) it also opens at once. On a phone the dimming behind - * it fades with it. Just after it opens, each time, its quote's highlight - * sweeps across the words. With reduced motion it simply shows and hides, - * the quote highlighted. + * A pop-over opens from what was clicked, as the video's does: it grows from + * 92% and fades in on the pop-over's spring, around the point nearest what + * opened it (`--grow-from`, from `growFrom` in `src/components/popover.ts`), + * and closes the same way. Where the browser can't keep it on top as it + * closes, without `overlay` (Firefox and Safari, MDN's compatibility data, + * read 2026-10-22), it closes at once; without `@starting-style` (before + * Chrome 117, Firefox 129 and Safari 17.5) it also opens at once. On a phone + * the dimming behind a source's fades with it. Just after a source's opens, + * each time, its quote's highlight sweeps across the words. With reduced + * motion a pop-over simply shows and hides, the quote highlighted. */ @media (prefers-reduced-motion: no-preference) { - .source-popover { + .popover { transform-origin: var(--grow-from, 50% 50%); opacity: 0; scale: 0.92; @@ -733,20 +771,20 @@ body { var(--spring-popover-fade), var(--spring-popover); } - .source-popover:popover-open { + .popover:popover-open { opacity: 1; scale: 1; } @starting-style { - .source-popover:popover-open { + .popover:popover-open { opacity: 0; scale: 0.92; } } @supports (overlay: auto) { - .source-popover { + .popover { transition-property: opacity, scale, display, overlay; transition-timing-function: var(--spring-popover-fade), var(--spring-popover), linear, linear; diff --git a/src/app/icon.svg b/src/app/icon.svg index 50f9bf3..0b15be5 100644 --- a/src/app/icon.svg +++ b/src/app/icon.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/components/popover.ts b/src/components/popover.ts new file mode 100644 index 0000000..8b1bd2b --- /dev/null +++ b/src/components/popover.ts @@ -0,0 +1,24 @@ +/** + * Moves a pop-over's transform origin to the point of its box nearest what + * opened it, so it grows from there (`--grow-from`, `.popover` in + * `src/app/globals.css`). Called by the opener's click, before the browser + * shows it: in the next frame, before it's painted, it's laid out where it + * opens. Offsets leave out the scale it grows from; for the top layer's fixed + * box they're the viewport's, as `getBoundingClientRect` gives the opener's. + */ +export function growFrom(popover: HTMLElement, opener: HTMLElement) { + requestAnimationFrame(() => { + if (!popover.matches(":popover-open")) return; + const from = opener.getBoundingClientRect(); + const x = from.left + from.width / 2 - popover.offsetLeft; + const y = from.top + from.height / 2 - popover.offsetTop; + popover.style.setProperty( + "--grow-from", + `${within(x, popover.offsetWidth)}px ${within(y, popover.offsetHeight)}px`, + ); + }); +} + +function within(offset: number, size: number): number { + return Math.round(Math.min(Math.max(offset, 0), size)); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 30f5faf..d01aa7e 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -11,7 +11,7 @@ export const buttonVariants = cva( variant: { primary: "bg-foreground text-background hover:bg-foreground/85", outline: - "border border-border-strong text-foreground hover:border-foreground hover:bg-foreground/10", + "border border-border-strong text-foreground hover:border-muted-foreground hover:bg-foreground/10", quiet: "text-muted-foreground underline underline-offset-4 hover:text-foreground", }, diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 8778aed..ab0b395 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -2,11 +2,13 @@ ## 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. +- **"Start over" asks first** (slice 28, `prd/start-over.tsx`): in a small pop-over under its button, "Clear the chat and the PRD?" with "Clear both" and "Keep them", so the row above the PRD keeps its two buttons and never wraps. It's a native `popover="auto"`, its button the invoker (`popoverTarget`, toggling), which gives light dismiss, Escape and the anchor, as the source's (below); `position-area: bottom span-left` on every screen, so it ends where its button ends. Focus goes to "Keep them" on its `toggle` event, since React's `autoFocus` focuses on mount, not when it opens (React's docs; MDN, *Popover API*, read 2026-10-26 through Context7); the browser gives focus back to the button on Escape or "Keep them". "Clear both" hides it first, then clears, and the intro's heading takes focus. +- **The palette** (slice 28, `src/app/globals.css`, `docs/decisions.md`, Design, 2026-10-26): `--background`, `--surface` and `--page` (the panels), `--border` (lines, also `--bubble`), `--border-strong` (a control's edge), and three text tones: `foreground` for headings, bold words, names and buttons, `prose` (`text-prose`) for the chat's replies and the PRD's statements, `muted-foreground` for the rest. A reply's bold words and its tables' headers, and what the code objects to under a statement, are `text-foreground` inside the prose. - **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, until a save of it in that answer comes through. A save that failed keeps it, since the assistant can try again: counting only a save in progress, it blinked off and on with each try while the database was down (found 2026-10-25). It's a sibling of the `

`, not inside it, so the heading's and the region's names don't change, and it carries `.shimmer`. A section saved, or saved again with different statements (compared as JSON), lights up: a decorative `span.just-changed` behind it, remounted with a new `key` on each change so the glow restarts without touching the heading's focus, a white tint that rises and fades as a statement's glow does in the README's video (`--duration-highlight`, 1.13s), which runs with reduced motion too, since a glow doesn't move. The sections the PRD mounts with, such as those brought back after a reload, and a save that changes nothing, don't count (`useChanges` in `prd/prd-document.tsx`, React's "storing information from previous renders" pattern). The title line's count rolls (`RollingNumber`, `.roll` in `src/app/globals.css`): plain text at rest, so a text query still finds the whole line; during a change the old number leaves upwards and the new one comes in from below, each moving half a line as it fades and sharpens from a 2px blur, over 0.4s on the ease-out, unclipped: a full line's slide in a box clipped with `clip-path: inset(0)`, 0.27s on the ease-in-out, crossed in a few frames and cut both numbers in half, which looked choppy (found 2026-10-25). With reduced motion the old number is `display: none` and no `animationend` comes, so the rolling markup stays until the next change. jsdom has no `AnimationEvent`, so React listens for a vendor-prefixed event there; `tests/unit/animation-event.ts` defines one before React loads. - **Desktop layout:** the body takes the viewport height only on pages that hold `[data-workspace]` (`lg:has-[[data-workspace]]:h-dvh` in `src/app/layout.tsx`, Tailwind's `has-*` variant), so the two panes scroll on their own and the transcripts page scrolls as before. On those pages the page is at most 1600px wide (`max-w-workspace`, from `--container-workspace` in `src/app/globals.css`). The chat is in front and the PRD a supporting column beside it (slice 27; `docs/decisions.md`, Design, 2026-10-22): the grid is `minmax(0,1fr) max(28rem,36%)`, so the PRD is 448px on a 1024px screen, where its text keeps about 45 characters a line, about 500 on a 1440px one, 559 at the page's full width, and the chat takes the rest (496, 859, 961). The PRD's page has 24px of padding a side there (`lg:px-6`), 40 when the panes stack and 20 on a phone. A saved section's heading row doesn't wrap: a long heading wraps its own words (`min-w-0`), and its copy button stays on its first line (`self-start mt-0.5`), since a wrapped row left a blank row where the button hides at rest. - **The chat's column** (slice 27): the chat's text, the visitor's messages, the notices, the suggestion and the message box share one column, at most 46rem (`--container-chat`, near opencode's 744px), centred in the chat however wide it gets. The message box, the suggestion and the allocation notice are `chat-column` (`mx-auto`); the conversation's pane spans the whole chat, so its scrollbar stays at the chat's edge, and is `chat-column-pane`: it centres its children with its left padding, `max(0px, (100% - 46rem) / 2)`, whose percentage is of the chat's width as the box's centring is, and caps each child at 46rem. So the conversation starts where the box starts, and where the chat has room, its questions end where the box ends. No container query: MDN's pages disagree on whether `container-type: inline-size` also adds layout containment (read 2026-10-23 through Context7), and the padding needs none. The header, the main area's padding and the footer take 114px of the height. The header is 45px on every page, not only the workspace, so it doesn't change height when the intro gives way to the chat; the focused skip link fits inside it. `tests/e2e/layout.spec.ts` measures all of it. -- **The typeface** (slice 18): Inter, through `next/font/google` in `src/app/layout.tsx` (Next.js 16, `next/font`, read 2026-10-21 through Context7): its files are downloaded at build time and served from `/_next/static/media/`, so the visitor's browser never asks Google, and the build needs Google Fonts to be reachable. Its licence is the SIL Open Font License 1.1 (https://github.com/rsms/inter, `LICENSE.txt`, read 2026-10-21), which allows bundling it with software. `variable: "--font-inter"` on `` puts it first in `--font-sans` (`src/app/globals.css`, in `@theme inline`, so the variable resolves where it's used: Tailwind, *Theme variables*, read 2026-10-21 through Context7), with the system fonts behind it in case it fails. Inter is variable, so 400, 500 and 600 come from one file. Against a jump, Next adds "Inter Fallback", `local(Arial)` resized to Inter's metrics, shown until Inter arrives. On Windows Chromium, with Inter held back, the transcripts page grew 20px of 7,216 when it arrived, against 72px with plain Arial, and the layout-shift score stayed near 0 in both, as the top of the page barely moves; so `tests/e2e/pages.spec.ts` checks the fallback's form, not a measured jump. Where Arial isn't installed (Linux, Android), the fallback is the system's font, unresized. +- **The 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 300 (the big headings, slice 28), 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`). An answer keeps one "Thinking" line from its first step to its reply's words: while a step runs between two waits it's parked, out of sight with `visibility: hidden` and `aria-hidden`, in its place, so shown again it neither blinks nor starts its band over; removed for each step and added anew, it did both at every step (found 2026-10-25). The unit tests count only the moving lines in sight. The band is the one shared class `.shimmer` in `src/app/globals.css`, for slices 13, 15 and 20 to reuse: a gradient clipped to the text, only under `prefers-reduced-motion: no-preference` and `forced-colors: none` and where `background-clip: text` is supported, so everywhere else the line is still and grey (MDN, `background-clip`, Accessibility, read 2026-10-20 through Context7). It is decoration: the screen reader's status line in `chat/chat.tsx` is unchanged. jsdom applies no media queries, so the unit tests check which line carries `.shimmer`, and `tests/e2e/chat.spec.ts` checks the animation and the still line with `page.emulateMedia({ reducedMotion })`. - **The search lines** (slice 15, `chat/search-step.tsx`, `chat/search-run.tsx`): opencode’s form at 50c5fce (`docs/decisions.md`, Design, 2026-10-20). Each search is a plain `

`, never a button: "Search" in medium weight, the query and what came of it in grey, 13px, one line cut off with `truncate` (a failure keeps its whole reason). The dot is `aria-hidden` and an `sr-only` "passages from" goes before the names. Searches in a row (`laidOut` in `chat/conversation.tsx`) fold into `SearchRun`: a button "Used 3 searches" with `aria-expanded`, whose count is the shared `RollingNumber` (`src/components/rolling-number.tsx`, also the PRD’s), then an inline SVG triangle, `-rotate-90` and `opacity-45` at rest, full on hover and turned down when open, through Tailwind’s `group-aria-expanded:`, turning in 0.4s on the ease-in-out as the lines slide (slice 26); its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). The box that clips them is positioned: the lines' `sr-only` labels are `absolute`, and placed against the chat's pane they escaped the clip and stretched the pane below its last line while a folded line was the answer's last (found 2026-10-22). Interpolating the row from `0fr` was checked in Chromium only (2026-10-20); where it doesn’t interpolate, the lines appear at once. Rows are `leading-7`, 8px apart, 4px under the button. While a run’s search runs, the run’s span carries `.shimmer`, and its white parts drop their colour, since `text-foreground` would cover the band. Its count, while it rolls, keeps its own grey (`.shimmer .roll`): the band paints the line through its see-through letters, and left out the rolling number, in a box of its own, so the count vanished for each roll (found 2026-10-25). The fake model searches three times when a message holds `FAKE_SEARCH_AGAIN`, which the end-to-end tests use. A run takes in the thought lines (below) in its unbroken stretch of searches and thoughts, before, between and after the searches, as opencode's `groupContent` does; a stretch with one search stays plain lines, and the count counts searches only. @@ -24,7 +26,7 @@ ## Motion (slices 25 and 26) - **One set of values** (`src/app/globals.css`, *Motion*), from the README's demo video (`docs/decisions.md`, Design, 2026-10-22; its project's `src/ui/motion.ts` and `src/Showcase.tsx`): its two eases; its three springs, sampled from Remotion 4.0.538's own `spring()` (`spring-utils.js` and `measure-spring.js` in the video project's `node_modules`) into `linear()` within 0.004, each as long as `measureSpring` says it takes to settle (0.4s, 0.5s, 0.77s); for each motion its fade, the video's `Math.min(1, p * k)` (1.4 for what arrives, 1.3 for a pane, 1.5 for the pop-over), as a `linear()` of its own, so things are whole while they still sharpen; and its durations, in its frames at 30 a second. The curves sit one a line under `/* prettier-ignore */`: to change one, sample it again rather than edit its stops. Without `linear()` (Chrome 113, Firefox 112, Safari 17.2, MDN's compatibility data) everything settles on the ease-out. Every motion in the app uses the set, Tailwind's own transitions too (`--default-transition-*` in `@theme inline`): the words, the fold's slide and its drawing together, the triangle's turn, the rolling count, the copy label and button, the just-changed glow and the pop-over. - **What arrives** (`.arrive`) rises a few pixels, sharpens from a slight blur and fades in, then stays still: two animations, the motion on its curve and the opacity on its fade. The video's distances halved: a line 5px; a block 8px (`.arrive-block`: a table, a statement, the suggestion, the check-in, the failure and allocation notices); the visitor's message 13px and from 90% of its size, from its bottom-right corner, on the small spring (`.arrive-message`); the chat and the PRD 9px on the pane spring (`.arrive-pane`); a search 15px from the left, fading on its motion's own curve, as the video's (`.arrive-search`). `--order` delays one 0.2s after another (a section's statements; the PRD's pane after the chat's), and `data-again` starts an arrival again by switching to copies of its keyframes, so a re-saved section's statements arrive again without remounting a focused name. `--arrive-*` and `--order` are registered with `inherits: false` (`@property`, as Tailwind 4 registers its own): inherited, the chat pane's 9px and the PRD pane's delay passed to everything in them (found 2026-10-22). `backwards` holds the first frame through a delay, and nothing stays once it has run, no transform or filter to make a containing block. Only under `prefers-reduced-motion: no-preference`. A step that arrives while it works, a search or a quote check, also carries `.shimmer`, whose `animation` shorthand mixed with `.arrive`'s parts: the line rose in again every 0.4s for as long as it worked, with no band, which showed only when a step waited, as on a slow or failing database (found 2026-10-25). `.arrive.shimmer` lists the three animations side by side, the arrival once and the band on, `.arrive` sets its count to 1, and `tests/e2e/chat.spec.ts` holds a search and a save in progress to check it. Any other class that animates an element that may arrive needs the same. -- **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. +- **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 arrives as the page first shows (below). - **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. A reply being typed out grows after its answer has ended, when the layout effect on the messages and the status no longer runs, so `RichText` calls `onGrow` (`followTyping`) before each paint while it types; it left the chat a line short of its end in 2 runs of 8 (found 2026-10-25). - **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. @@ -54,6 +56,8 @@ - **What it sends:** a step's words, through `playScenario`, which leaves the intro as its button does and clears a pending check-in. A step counts as played once its words are among the visitor's messages (`playedScenarios` in `src/lib/scenarios.ts`), so the tour survives a reload and "Start over" begins it again; "Play next" offers the step after the furthest one played. Both are disabled while an answer is on its way. ## The intro (`workspace/intro.tsx`, `workspace/disclosure.ts`) +- **Its form** (slice 28, `docs/decisions.md`, Design, 2026-10-26): centred, at most 48rem wide; the heading in weight 300; one short line; "Interviewed:" and the six names, each a link to `/transcripts#`, the interview's anchor on the transcripts page; the buttons; then `Glimpse`, an inline SVG of the chat beside the PRD in bars, `aria-hidden`, faded at its foot (`mask-b-from-40%`, Tailwind 4.1's mask utilities), `hidden sm:block`. The notices (a discarded PRD, a failed first request, the allocation) are left-aligned boxes in the centred column. At 1280×720 the glimpse ends inside the window (`tests/e2e/intro.spec.ts`). +- **Its arrival:** its lines are `.arrive` with `--order` 0 to 4, the glimpse `arrive-pane` and last, in the server's page as in the browser's. The browser's intro replaces the server's a moment after the first paint, which would start every arrival again; so `useArrivalSinceFirstPaint`, in a layout effect, sets each of its animations' `startTime` to the `first-contentful-paint` entry's `startTime`, both on the document's timeline (MDN, `Animation.startTime`, `Document.timeline` and `PerformancePaintTiming`, read 2026-10-26 through Context7). They go on where the server's page had got to, or are over, and an intro brought back later is simply there. Without a paint entry they run from now. On the dev server the first paint can come seconds after the page is asked for, so a screenshot then catches the arrival at its start. - **What shows when:** the intro until the visitor leaves it or the conversation holds messages (`conversationStarted`, which holds for a restored conversation too); the suggestion, "Add grocery delivery to the PRD.", once a section is saved, until the visitor has sent it (`offeredSuggestion`). Its button and the suggestion import their words from `src/lib/suggestions.ts`, never a copy: the measurement sends the intro's words too, and changing them means a new baseline. The suggestion itself isn't measured; the measurement's grocery delivery claim (`GROCERY_DELIVERY_CLAIM`) was the suggestion until 2026-10-22. - **A failed first request:** `useChat`'s `onFinish` runs after every answer, once the error status is set, with `isError` and the messages from the chat's own state (ai 7.0.143, `makeRequestImpl` in `node_modules/ai/dist/index.js`, read 2026-10-19; the AI SDK's `useChat` reference through Context7). `afterAnswer` brings the intro back only when the intro's request failed before a section was saved, and clears the error, so the chat never opens on the old one. The server can send an error after a save (a reply cut off at its output limit, or the provider failing on the last call); then the section stays, with the chat's "Try again". Don't decide this in `onError`: it runs before the status is set, and the rendered `messages` can lag behind the chat's state. A ref marks the intro's request, so a later failure stays in the chat. - **The message box's text lives in `Workspace`,** passed to `Chat`: the chat unmounts when the intro comes back, and what the visitor typed during a failed first request must survive. "Start over" leaves it, as before the intro. @@ -67,7 +71,7 @@ - **Busy:** an error whose message is `DEMO_BUSY` shows those words with "Try again", since a minute later it can work. Every other error keeps the generic sentence. ## The source pop-over (`prd/source-popover.tsx`) -- **No library:** one native `popover="auto"` element serves every name. Each name is its invoker (`popoverTarget`, `popoverTargetAction="show"`), which gives light dismiss (a tap outside, Escape) and makes the name the pop-over's implicit anchor, so the CSS needs no `anchor-name` (MDN, *Using the Popover API*, "Popover anchor positioning", read 2026-10-18 through Context7). +- **No library:** one native `popover="auto"` element serves every name. It shares its look and its motion with "Start over"'s (`.popover` in `src/app/globals.css`; `.source-popover` adds its sizes and placement), and both grow from what opened them through `growFrom` (`src/components/popover.ts`). Each name is its invoker (`popoverTarget`, `popoverTargetAction="show"`), which gives light dismiss (a tap outside, Escape) and makes the name the pop-over's implicit anchor, so the CSS needs no `anchor-name` (MDN, *Using the Popover API*, "Popover anchor positioning", read 2026-10-18 through Context7). - **Position** (`.source-popover` in `src/app/globals.css`): on a phone it isn't anchored, nearly full width and scrolling inside. From `lg` up it uses `position-area` with `position-try-fallbacks`, inside `@supports (position-area: bottom)`, with `inset` and `margin` reset as MDN advises, so browsers without anchor positioning get the centred box. Support (MDN's compatibility data, read 2026-10-18): Chrome 129, Firefox 147, Safari 26. - **Highlighting** (`src/lib/highlight.ts`) reads a quote the way the check does, only to mark the words; whether a quote is confirmed is the check's call alone. - **Its motion** (slice 26, `.source-popover` and `.quote-mark` in `src/app/globals.css`): it grows from 92% and fades in on the pop-over spring, with `@starting-style`, and closes the same way with `display` and `overlay` among the transitions (`transition-behavior: allow-discrete`), which keep it shown and on top until it's done; closing, it takes no clicks. Support (MDN's compatibility data, read 2026-10-22): `@starting-style` Chrome 117, Firefox 129, Safari 17.5; `overlay` Chrome 117 only, so the closing transition sits in `@supports (overlay: auto)` and elsewhere it closes at once (in Firefox `display` doesn't transition either). It grows from its name: `openFrom` in `prd/source-popover.tsx`, run by the name's click before the browser shows it, sets `--grow-from` in the next frame, before the paint, to the point of its box nearest the name, from `offsetLeft` and `offsetTop`, which leave out the scale (for the top layer's fixed box they're the viewport's, as `getBoundingClientRect` gives the name's, with the page scrolled too: Chromium, 2026-10-22). On a phone, unanchored, it grows from its edge nearest the name. Its passages remount at each opening (`opened`), and from 0.4s after it opens the highlight (`.quote-mark`, a gradient, not a background colour) sweeps across the words in 0.8s on the ease-in-out, as `background-size` grows, in reading order across its lines (`box-decoration-break: slice`, the default). On a phone the dimming behind it fades in and out with it. diff --git a/src/features/chat/conversation.tsx b/src/features/chat/conversation.tsx index 97c1fa2..fd34081 100644 --- a/src/features/chat/conversation.tsx +++ b/src/features/chat/conversation.tsx @@ -323,7 +323,10 @@ function AssistantMessage({ text={part.text} arriving={arriving} onGrow={onTextGrows} - className={cn(space, unfinished && "text-muted-foreground")} + className={cn( + space, + unfinished ? "text-muted-foreground" : "text-prose", + )} /> ); case "tool-searchNotes": diff --git a/src/features/chat/rich-text.tsx b/src/features/chat/rich-text.tsx index 4e68a8b..f34b90c 100644 --- a/src/features/chat/rich-text.tsx +++ b/src/features/chat/rich-text.tsx @@ -256,7 +256,10 @@ function table({ header, rows }: Table, words: Words | undefined) { {runs(cell, words)} @@ -342,7 +345,7 @@ function runs(line: Inline[], words: Words | undefined) { case "bold": if (unreached(words)) return null; return ( - + {wordsOf(run.text, words)} ); diff --git a/src/features/prd/prd-document.tsx b/src/features/prd/prd-document.tsx index fe065b4..e39faf6 100644 --- a/src/features/prd/prd-document.tsx +++ b/src/features/prd/prd-document.tsx @@ -22,6 +22,7 @@ import { cn } from "@/lib/utils"; import { CopySection } from "./copy-section"; import { downloadPrd, PRODUCT, VISITOR_DECISION } from "./prd-markdown"; import { openFrom, SOURCE_POPOVER_ID, SourcePopover } from "./source-popover"; +import { StartOver } from "./start-over"; /** The check-in under the latest saved section. */ export type CheckIn = { @@ -82,7 +83,8 @@ export function PrdDocument({ aria-labelledby="prd-title" className="flex flex-col gap-3 lg:min-h-0 lg:flex-1" > -

+ {/* One row that never wraps: "Start over" asks in its pop-over. */} +
{sections.size > 0 && ( - ); - } - return ( -
-

This clears the chat and the PRD.

- - -
- ); -} diff --git a/src/features/prd/source-popover.tsx b/src/features/prd/source-popover.tsx index b1f9963..496915d 100644 --- a/src/features/prd/source-popover.tsx +++ b/src/features/prd/source-popover.tsx @@ -1,3 +1,4 @@ +import { growFrom } from "@/components/popover"; import { highlightQuote } from "@/lib/highlight"; import { VISITOR_NAME, type StatementSource } from "@/lib/prd"; @@ -7,11 +8,8 @@ export const SOURCE_POPOVER_ID = "source-popover"; * Readies the pop-over for the name just clicked, whose own click then shows * it. Open, or still closing, for another name, it shuts at once, without * its closing transition: the browser keeps an open pop-over by the name that - * opened it, so it opens anew by this one. In the next frame, before it's - * painted, it's laid out there, and its transform origin moves to the point - * nearest the name, so it grows from the name (`--grow-from`, - * `.source-popover` in `src/app/globals.css`); offsets leave out the scale it - * grows from. + * opened it, so it opens anew by this one. It grows from the name + * (`growFrom`). */ export function openFrom(name: HTMLElement) { const popover = document.getElementById(SOURCE_POPOVER_ID); @@ -24,20 +22,7 @@ export function openFrom(name: HTMLElement) { // Takes in the closed state, so it opens from its starting style. popover.getBoundingClientRect(); popover.style.removeProperty("transition"); - requestAnimationFrame(() => { - if (!popover.matches(":popover-open")) return; - const from = name.getBoundingClientRect(); - const x = from.left + from.width / 2 - popover.offsetLeft; - const y = from.top + from.height / 2 - popover.offsetTop; - popover.style.setProperty( - "--grow-from", - `${within(x, popover.offsetWidth)}px ${within(y, popover.offsetHeight)}px`, - ); - }); -} - -function within(offset: number, size: number): number { - return Math.round(Math.min(Math.max(offset, 0), size)); + growFrom(popover, name); } /** @@ -65,7 +50,7 @@ export function SourcePopover({ popover="auto" role="dialog" aria-labelledby="source-popover-title" - className="source-popover" + className="popover source-popover" > {source && (
diff --git a/src/features/prd/start-over.tsx b/src/features/prd/start-over.tsx new file mode 100644 index 0000000..80ea398 --- /dev/null +++ b/src/features/prd/start-over.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { growFrom } from "@/components/popover"; +import { Button } from "@/components/ui/button"; + +const START_OVER_ID = "start-over"; + +/** + * "Start over", which asks once before it clears the chat and the PRD, in a + * small pop-over under its button: the source's pop-over in look and + * behaviour (`.popover` in `src/app/globals.css`), so a click outside or + * Escape closes it, and the row the button sits in never changes. It opens + * with focus on "Keep them", the choice that loses nothing; the browser + * gives focus back to the button when it closes. + */ +export function StartOver({ onConfirm }: { onConfirm: () => void }) { + const popover = useRef(null); + const keep = useRef(null); + + // React's `autoFocus` focuses on mount, not when the pop-over opens. + useEffect(() => { + const element = popover.current; + if (!element) return; + const onToggle = (event: Event) => { + if ((event as ToggleEvent).newState === "open") keep.current?.focus(); + }; + element.addEventListener("toggle", onToggle); + return () => element.removeEventListener("toggle", onToggle); + }, []); + + return ( + <> + + + + ); +} diff --git a/src/features/workspace/intro.tsx b/src/features/workspace/intro.tsx index 041f989..c5e44a5 100644 --- a/src/features/workspace/intro.tsx +++ b/src/features/workspace/intro.tsx @@ -1,4 +1,10 @@ import Link from "next/link"; +import { + useLayoutEffect, + useRef, + type CSSProperties, + type RefObject, +} from "react"; import { Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { QuotaNotice } from "./quota"; @@ -6,11 +12,24 @@ import { QuotaNotice } from "./quota"; /** The intro's heading, which takes focus when the intro comes back. */ export const INTRO_TITLE_ID = "intro-title"; +/** The interviewed households, by the slug of each one's interview. */ +const HOUSEHOLDS = [ + ["Maya", "maya"], + ["Henk", "henk"], + ["Daan", "daan"], + ["Sofia", "sofia"], + ["Lena", "lena"], + ["Ravi", "ravi"], +] as const; + /** - * The first screen of the home page: who the visitor is, what the interviews - * are, and what their job is, then one button that drafts the first section. + * The first screen of the home page, centred on one screen with room around + * it: who the visitor is, one line on what they'll do, the households with + * a link to each interview, then one button that drafts the first section. * Quieter, the full interviews and a way to start with a question instead, - * which the owner decided to keep (docs/decisions.md, Design). + * which the owner decided to keep, and a softened glimpse of the chat beside + * the PRD, where they're headed (docs/decisions.md, Design). It arrives line + * by line, the glimpse last, as the page first shows. */ export function Intro({ failed, @@ -36,34 +55,52 @@ export function Intro({ onDraft?: () => void; onAsk?: () => void; }) { + const intro = useRef(null); + useArrivalSinceFirstPaint(intro, onDraft !== undefined); + // Not a named region: its heading names a "shared grocery list app", and // the PRD is the region of that name. return ( -
+

You're the product manager of a shared grocery list app

-
-

- A researcher already interviewed six households about it. Those - transcripts are finished records: the AI reads and quotes them, and - never asks their questions again. -

-

- Your job is to turn them into a product requirements document, a PRD. - The AI proves every line with a quote, and the app's own code - checks each one. -

-
+

+ Turn six household interviews into its product requirements document. + The app checks every quote word for word. +

+

+ Interviewed:{" "} + {HOUSEHOLDS.map(([name, slug], index) => ( + + {index > 0 && (index === HOUSEHOLDS.length - 1 ? " and " : ", ")} + + {name} + + + ))} +

{discarded && (

The PRD you started earlier couldn't be brought back: the demo has changed since. You can start a new one. @@ -71,10 +108,13 @@ export function Intro({ )} {quotaUsedUp ? ( - + ) : ( failed && ( -

+

The first section wasn't drafted: something went wrong before the assistant could finish. Try again in a moment. @@ -83,7 +123,10 @@ export function Intro({ ) )} -

+
{!quotaUsedUp && (