From 7e95be7fa205ae38d2dbbf6a34cde22815baf184 Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Wed, 21 Oct 2026 09:51:22 +0200 Subject: [PATCH] Show the app in Inter, served by the app itself Co-Authored-By: Claude Opus 5.5 --- docs/plan.md | 12 ++++----- src/app/globals.css | 5 ++-- src/app/layout.tsx | 9 ++++++- src/features/AGENTS.md | 1 + tests/e2e/pages.spec.ts | 58 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 9 deletions(-) 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 ." as the visitor's message, where the next is the first section in the PRD's order not drafted yet. It counts as arrived once a save of that section comes after the message it sent; until then it gives way, while the assistant works, to that section's own "Drafting…", and comes back after a failed or empty answer. - **What the PRD shows changing** (slice 13): "Drafting…" sits beside the heading of each section being drafted while the assistant works (`sectionsBeingDrafted` in `workspace/workspace.tsx`): the check-in's section from its message until it arrives, and any section whose save has started in the answer on its way. It's a sibling of the `

`, not inside it, so the heading's and the region's names don't change, and it carries `.shimmer`. A section saved, or saved again with different statements (compared as JSON), lights up: a decorative `span.just-changed` behind it, remounted with a new `key` on each change so the fade restarts without touching the heading's focus, a white tint fading to transparent in 2s, which runs with reduced motion too, since a fade doesn't move. The sections the PRD mounts with, such as those brought back after a reload, and a save that changes nothing, don't count (`useChanges` in `prd/prd-document.tsx`, React's "storing information from previous renders" pattern). The title line's count rolls (`RollingNumber`, `.roll` in `src/app/globals.css`): plain text at rest, so a text query still finds the whole line; during a change the old number leaves and the new one comes in, clipped with `clip-path: inset(0)`, since `overflow: hidden` would move an inline block's baseline (MDN, `clip-path`, read 2026-10-20 through Context7). With reduced motion the old number is `display: none` and no `animationend` comes, so the rolling markup stays until the next change. jsdom has no `AnimationEvent`, so React listens for a vendor-prefixed event there; `tests/unit/animation-event.ts` defines one before React loads. - **Desktop layout:** the body takes the viewport height only on pages that hold `[data-workspace]` (`lg:has-[[data-workspace]]:h-dvh` in `src/app/layout.tsx`, Tailwind's `has-*` variant), so the two panes scroll on their own and the transcripts page scrolls as before. On those pages the page is at most 1600px wide (`max-w-workspace`, from `--container-workspace` in `src/app/globals.css`), and the chat's column is `clamp(25rem, 38%, 32.5rem)`: 400px on a 1024px screen, 520px from about 1416px, with the PRD taking the rest (`docs/decisions.md`, Design, 2026-10-20). The header, the main area's padding and the footer take 114px of the height. The header is 45px on every page, not only the workspace, so it doesn't change height when the intro gives way to the chat; the focused skip link fits inside it. `tests/e2e/layout.spec.ts` measures all of it. +- **The typeface** (slice 18): Inter, through `next/font/google` in `src/app/layout.tsx` (Next.js 16, `next/font`, read 2026-10-21 through Context7): its files are downloaded at build time and served from `/_next/static/media/`, so the visitor's browser never asks Google, and the build needs Google Fonts to be reachable. Its licence is the SIL Open Font License 1.1 (https://github.com/rsms/inter, `LICENSE.txt`, read 2026-10-21), which allows bundling it with software. `variable: "--font-inter"` on `` puts it first in `--font-sans` (`src/app/globals.css`, in `@theme inline`, so the variable resolves where it's used: Tailwind, *Theme variables*, read 2026-10-21 through Context7), with the system fonts behind it in case it fails. Inter is variable, so 400, 500 and 600 come from one file. Against a jump, Next adds "Inter Fallback", `local(Arial)` resized to Inter's metrics, shown until Inter arrives. On Windows Chromium, with Inter held back, the transcripts page grew 20px of 7,216 when it arrived, against 72px with plain Arial, and the layout-shift score stayed near 0 in both, as the top of the page barely moves; so `tests/e2e/pages.spec.ts` checks the fallback's form, not a measured jump. Where Arial isn't installed (Linux, Android), the fallback is the system's font, unresized. - **The chat's spacing:** 16px from a question to its answer, or to "Working…", 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 "Working…" at the answer's end whenever no step runs and no text arrives. Text at the answer's end counts as arriving even once its part is `done`: a reply's steps come after its text, so trailing text means the answer is finishing, and a "Working…" that flashed there before the stream closed left the chat 48px off its end, so it stopped following when the message box grew (found 2026-10-20 by `tests/e2e/layout.spec.ts`). Nothing moves once the status leaves `submitted`/`streaming` (`activityOf` in `chat/conversation.tsx`). The band is the one shared class `.shimmer` in `src/app/globals.css`, for slices 13, 15 and 20 to reuse: a gradient clipped to the text, only under `prefers-reduced-motion: no-preference` and `forced-colors: none` and where `background-clip: text` is supported, so everywhere else the line is still and grey (MDN, `background-clip`, Accessibility, read 2026-10-20 through Context7). It is decoration: the screen reader's status line in `chat/chat.tsx` is unchanged. jsdom applies no media queries, so the unit tests check which line carries `.shimmer`, and `tests/e2e/chat.spec.ts` checks the animation and the still line with `page.emulateMedia({ reducedMotion })`. - **The search lines** (slice 15, `chat/search-step.tsx`, `chat/search-run.tsx`): opencode’s form at 50c5fce (`docs/decisions.md`, Design, 2026-10-20). Each search is a plain `

`, never a button: "Search" in medium weight, the query and what came of it in grey, 13px, one line cut off with `truncate` (a failure keeps its whole reason). The dot is `aria-hidden` and an `sr-only` "passages from" goes before the names. Searches in a row (`laidOut` in `chat/conversation.tsx`) fold into `SearchRun`: a button "Used 3 searches" with `aria-expanded`, whose count is the shared `RollingNumber` (`src/components/rolling-number.tsx`, also the PRD’s), then an inline SVG triangle, `-rotate-90` and `opacity-45` at rest, full on hover and turned down when open, through Tailwind’s `group-aria-expanded:`; its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). Interpolating the row from `0fr` was checked in Chromium only (2026-10-20); where it doesn’t interpolate, the lines appear at once. Rows are `leading-7`, 8px apart, 4px under the button. While a run’s search runs, the run’s span carries `.shimmer`, and its white parts drop their colour, since `text-foreground` would cover the band. The fake model searches three times when a message holds `FAKE_SEARCH_AGAIN`, which the end-to-end tests use. 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);