diff --git a/docs/plan.md b/docs/plan.md index 8109462..3b06a7c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,12 +20,12 @@ ### Build lane - **Where:** its own worktree per piece, on a branch from an up-to-date `main`. If the session isn't in a worktree yet (`git worktree list` shows its folder as the main checkout, not under `.claude/worktrees/`), it creates one before touching anything, and never checks out a branch in the main folder, where the measurement lane works. Claude Code's worktree option puts it under `.claude/worktrees/`; run `npm ci` there first. A worktree made with `git worktree add` gets no `.env.local`: run `npm run env:local` there, or the end-to-end tests find no interviews. Its browser check uses `app-build` in `.claude/launch.json` (port 3200), never `app` or `app-real`, which are the measurement lane's on port 3000. It never runs `measure`, `measure:fake` or `dev:real`, never edits the measurement lane's files above, and changes code in `src/server` only where its piece needs it, never what the model receives (listed above); its pull request says which `src/server` files it changed and why. -- **Doing:** slice 17 is done on `feat/copy-section` (`.claude/worktrees/slice-17-copy`), from `main` at 3bc53f8 (slice 16's merge), waiting for the owner's push and merge. Local checks on 2026-10-21: `verify` (472 unit tests, 8 new in `tests/unit/prd-markdown.test.ts` and `tests/unit/prd-document.test.tsx`) and `test:e2e` (63, 2 new in `tests/e2e/prd.spec.ts`) passed in one full run; the two new tests passed 3 times of 3 with two workers, and at full parallelism 4 of 6 waited too long for the fake model's answer (KI-7). The page checked on a dev server from the worktree with the fakes, at the pane's size: the button 24px with a 14px grey icon, 12px after the tally, centred on the heading, transparent at rest and shown on hover with a faint background, "Copy section" 4px above it after the pause, in 11px medium weight. The pane was hidden, and its browser refused the clipboard, so the click showed "Couldn't copy" on screen and in the live region, back to the copy button after two seconds; a successful copy was seen only in the end-to-end tests' Chromium. It changes nothing in `src/server` or `src/lib`, so the measurement lane needn't wait for it. +- **Doing:** slice 18 is done on `feat/designed-typeface` (`.claude/worktrees/slice-18-font`), from `main` at 3b9be95 (slice 17's merge), kept by the owner after a look on 2026-10-21, waiting for their push and merge. Inter's licence, the SIL Open Font License 1.1, was checked first (https://github.com/rsms/inter, read 2026-10-21). Local checks on 2026-10-21: `verify` (472 unit tests) and `test:e2e` (64, 1 new in `tests/e2e/pages.spec.ts`) passed in one full run; the new test failed on `main`'s layout ("ui-sans-serif" where it expects "Inter"). The page checked on a dev server from the worktree with the fakes, at 1440px and 375px: Inter in the body and the headings, its one file from `/_next/static/media/`, no request to another host, no sideways scrolling on the phone. With Inter held back in Chromium, the transcripts page grew 20px of 7,216 when it arrived, against 72px with plain Arial (`src/features/AGENTS.md`, *The typeface*). It changes nothing in `src/server` or `src/lib`, so the measurement lane needn't wait for it. - **Next:** - 1. Slices 18 to 20 in order, each on its own branch from `main` once the one before is merged. They change only the browser's code (`src/features`, `src/components`, `src/app`'s styles and layout) and its tests, so the measurement lane needn't wait for them, except slice 19 (`src/lib/markdown.ts`, which the model never sees); its pull request says so. None changes `src/server`. + 1. Slices 19 and 20 in order, each on its own branch from `main` once the one before is merged. They change only the browser's code (`src/features`, `src/components`, `src/app`'s styles and layout) and its tests, so the measurement lane needn't wait for them, except slice 19 (`src/lib/markdown.ts`, which the model never sees); its pull request says so. None changes `src/server`. 2. Slice 8, the rate limit, once 9d is done and the owner has decided its legal basis and size (*Deferred*). -- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' fade; the copy button, its label and the clipboard). On staging, a reply that arrives whole fading in as one, and a section's copy over HTTPS pasted into another app. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token. -- **Waiting on the owner:** pushing `feat/copy-section`, its pull request and the merge; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. +- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once; the words' fade; 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). On staging, a reply that arrives whole fading in as one, and a section's copy over HTTPS pasted into another app. Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token. +- **Waiting on the owner:** pushing `feat/designed-typeface`, 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. @@ -109,9 +109,9 @@ ### 16 · New text fades in · risk: normal · status: done (0031422, with 6e98554) -### 17 · Copy a section, with a "Copied" check · risk: normal · status: done (on `feat/copy-section`; its id on `main` goes here after the merge) +### 17 · Copy a section, with a "Copied" check · risk: normal · status: done (3b9be95) -### 18 · A designed typeface · risk: normal · status: todo +### 18 · A designed typeface · risk: normal · status: done (on `feat/designed-typeface`; its id on `main` goes here after the merge) - **Goal:** the app looks the same on every device, in a typeface chosen for it. - **Acceptance:** - Inter, served by the app itself through Next.js's font loader (no new package, and no request from the visitor's browser to Google), replaces the system font everywhere. Sizes, weights and spacing stay as decided (Design, 2026-10-18); its licence is checked before it's added. diff --git a/src/app/globals.css b/src/app/globals.css index 00554f1..ed93d18 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -28,9 +28,10 @@ --color-border-strong: var(--border-strong); --color-page: var(--page); --color-bubble: var(--bubble); + /* Inter from `next/font` in `layout.tsx`; the system's font only if it fails. */ --font-sans: - ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; + var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; --radius-control: 0.25rem; /* The workspace's page: wider than 1280px, so the PRD gets its full page. */ --container-workspace: 100rem; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c014f01..d9df6f3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Inter } from "next/font/google"; import Link from "next/link"; import type { ReactNode } from "react"; import { site, siteUrl } from "@/lib/site"; @@ -11,9 +12,15 @@ export const metadata: Metadata = { applicationName: site.name, }; +// Inter (SIL Open Font License 1.1), downloaded at build time and served by +// the app itself, so the visitor's browser never asks Google for it. Its +// fallback is a local font resized to Inter's metrics, so the text doesn't +// jump when Inter arrives (Next.js, `next/font`, read 2026-10-21). +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); + export default function RootLayout({ children }: { children: ReactNode }) { return ( - +
looks right. Please draft`, never a button: "Search" in medium weight, the query and what came of it in grey, 13px, one line cut off with `truncate` (a failure keeps its whole reason). The dot is `aria-hidden` and an `sr-only` "passages from" goes before the names. Searches in a row (`laidOut` in `chat/conversation.tsx`) fold into `SearchRun`: a button "Used 3 searches" with `aria-expanded`, whose count is the shared `RollingNumber` (`src/components/rolling-number.tsx`, also the PRD’s), then an inline SVG triangle, `-rotate-90` and `opacity-45` at rest, full on hover and turned down when open, through Tailwind’s `group-aria-expanded:`; its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). Interpolating the row from `0fr` was checked in Chromium only (2026-10-20); where it doesn’t interpolate, the lines appear at once. Rows are `leading-7`, 8px apart, 4px under the button. While a run’s search runs, the run’s span carries `.shimmer`, and its white parts drop their colour, since `text-foreground` would cover the band. The fake model searches three times when a message holds `FAKE_SEARCH_AGAIN`, which the end-to-end tests use. diff --git a/tests/e2e/pages.spec.ts b/tests/e2e/pages.spec.ts index 88bc2b4..d76d9a5 100644 --- a/tests/e2e/pages.spec.ts +++ b/tests/e2e/pages.spec.ts @@ -76,6 +76,64 @@ test("every page leads engineers to the README's explanation of the search and t } }); +test("every page shows Inter, served by the app itself, with a fallback resized to it", async ({ + page, + baseURL, +}) => { + const requests: string[] = []; + const fonts: string[] = []; + page.on("request", (request) => { + requests.push(request.url()); + if (request.resourceType() === "font") fonts.push(request.url()); + }); + + for (const path of ["/", "/transcripts"]) { + await page.goto(path); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + const shown = await page.evaluate(async () => { + await document.fonts.ready; + const families = getComputedStyle(document.body) + .fontFamily.split(",") + .map((family) => family.trim().replace(/^["']|["']$/g, "")); + const faces = [...document.styleSheets].flatMap((sheet) => + [...sheet.cssRules].filter( + (rule): rule is CSSFontFaceRule => rule instanceof CSSFontFaceRule, + ), + ); + const fallback = faces.find( + (face) => + face.style.getPropertyValue("font-family").replace(/["']/g, "") === + families[1], + ); + return { + families: families.slice(0, 2), + loaded: [...document.fonts].some( + (font) => + font.family.replace(/["']/g, "") === families[0] && + font.status === "loaded", + ), + fallbackSource: fallback?.style.getPropertyValue("src") ?? "", + fallbackSizeAdjust: + fallback?.style.getPropertyValue("size-adjust") ?? "", + }; + }); + + expect(shown.families[0]).toBe("Inter"); + expect(shown.families[1]).toBe("Inter Fallback"); + expect(shown.loaded).toBe(true); + expect(shown.fallbackSource).toMatch(/^local\(/); + expect(shown.fallbackSizeAdjust).toMatch(/%$/); + } + + expect(fonts.length).toBeGreaterThan(0); + for (const font of fonts) { + expect(font.startsWith(`${baseURL}/_next/static/media/`)).toBe(true); + } + expect( + requests.filter((url) => /google|gstatic/.test(new URL(url).hostname)), + ).toEqual([]); +}); + test("every page tells search engines not to index it", async ({ request }) => { for (const path of ["/", "/transcripts"]) { const response = await request.get(path);