From e880fa2acebb2296b10caed31181714b50f79c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 23:14:35 +0000 Subject: [PATCH 01/12] Favourites mockup: the list is the page, measured on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /mockups/favourites-phone-perfected — one perfected phone-first direction for the signed-in Favourites page, drawn across the nine states that actually occur plus a desktop reference. Measured at 390x844 against the dev server with getBoundingClientRect(): the shipped /favourites puts the first row of the saved list at y=1141, about 300px below the fold, behind a hint strip, an in-flow composer, a privacy notice, a results band, a Continue card and a Recent card, and spends 228px on each item card. Nothing of the library is on the first screen. This direction spends 165px of chrome and 72px a row: seven rows fully above the fold, an eighth partly. Five decisions: one header rather than six bands; sets as the primary navigation; one-line rows; a real control for `pinnedAt`, which has been in the schema and the PATCH contract with no UI anywhere; and the shared composer kept as the only input, which the one-composer contract requires and the six existing favourites mockups each break with a second search bar. Draws only service | form | differential | therapy, the four types favouriteContentTypeSchema permits. The existing favourites mockups draw saved medications, documents, quotes and searches, none of which has a content type and none of which can be persisted. A weighted segment track was tried first and dropped: eight sets across 390px leaves each segment about 48px, under the width a set name needs. Phone frame geometry and both desktop grid tracks are pinned inline. On this route --spacing-phone-frame resolved to the empty string and the frame rendered 2661px tall with square corners, and the desktop grid collapsed to one stacked column — the same class-generation trap mockups/README.md records against a bare grid-cols-6. tests/favourites-phone-perfected-mockups.test.ts pins the content-type honesty, the controlled set vocabulary, clinical-state colour staying off decoration, and the 48px tap knob. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QtKvCY1d3cGd5EBvGNQouv --- data/repo-awareness-snapshot.json | 13 +- docs/site-map.md | 1 + mockups/README.md | 45 ++ .../favourites-phone-perfected/page.tsx | 12 + src/app/mockups/mockups-layout-client.tsx | 13 +- .../favourites-phone-perfected-page.tsx | 543 ++++++++++++++++++ .../favourites-phone-shell.tsx | 313 ++++++++++ .../favourites-rows.tsx | 477 +++++++++++++++ .../fixtures.ts | 441 ++++++++++++++ .../index.ts | 1 + ...favourites-phone-perfected-mockups.test.ts | 99 ++++ 11 files changed, 1952 insertions(+), 6 deletions(-) create mode 100644 src/app/mockups/favourites-phone-perfected/page.tsx create mode 100644 src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx create mode 100644 src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx create mode 100644 src/components/favourites-phone-perfected-mockups/favourites-rows.tsx create mode 100644 src/components/favourites-phone-perfected-mockups/fixtures.ts create mode 100644 src/components/favourites-phone-perfected-mockups/index.ts create mode 100644 tests/favourites-phone-perfected-mockups.test.ts diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index fb0c0caaba..08eb465999 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "c19d2cb01243ef93dbd8464d756fe0b6c21c3b5c", - "committed_at": "2026-08-26T22:58:40+08:00" + "sha": "8b92561e9023c221714980facb878d575827b4b7", + "committed_at": "2026-08-26T16:36:11+00:00" }, "routes": { "modes": [ @@ -688,6 +688,11 @@ "file": "src/app/mockups/favourites-library-view/page.tsx", "area": "mockup" }, + { + "path": "/mockups/favourites-phone-perfected", + "file": "src/app/mockups/favourites-phone-perfected/page.tsx", + "area": "mockup" + }, { "path": "/mockups/favourites-review-console", "file": "src/app/mockups/favourites-review-console/page.tsx", @@ -1383,9 +1388,9 @@ ], "counts": { "modes": 15, - "pages": 193, + "pages": 194, "product_pages": 55, - "mockup_pages": 138, + "mockup_pages": 139, "redirects": 17, "api": 57 } diff --git a/docs/site-map.md b/docs/site-map.md index 687e0c6c5f..0f4f73a1b6 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1215,6 +1215,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-phone-perfected` - Route discovered from app directory Source: `src/app/mockups/favourites-phone-perfected/page.tsx`. - `/mockups/favourites-review-console` - Route discovered from app directory Source: `src/app/mockups/favourites-review-console/page.tsx`. - `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. - `/mockups/favourites-set-navigator` - Route discovered from app directory Source: `src/app/mockups/favourites-set-navigator/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index a8fd2d1ac1..8a85f2fcac 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -97,6 +97,51 @@ no production file uses a bare `grid-cols-6` (only `xl:grid-cols-6`). The 26-let `gridTemplateColumns` inline rather than depending on class generation — a bare `grid-cols-6` silently collapses them to one column. +## Favourites, phone-first (2026-08-26) + +Runnable study at [`/mockups/favourites-phone-perfected`](../src/app/mockups/favourites-phone-perfected/page.tsx). +One perfected direction rather than a set of alternatives — the six earlier favourites studies +(`favourites-command-desk`, `-command-console`, `-library-view`, `-review-console`, `-set-board`, +`-set-navigator`) already covered the option space. + +**The measurement it answers.** Chromium at 390 × 844 against the dev server, reading +`getBoundingClientRect()` on the live `/favourites` route: the first row of the saved list begins at +**y = 1141**, roughly 300px below the fold, behind a hint strip, an in-flow composer, a privacy +notice, a results band, a Continue card and a Recent card. Each item card is **228px**. Nothing of +the library itself is on the first screen. This direction spends **165px** of app chrome and **72px** +a row, which puts **seven rows fully above the fold and an eighth partly**. + +| Decision | Trade-off | +| ---------------------------------------- | ---------------------------------------------------------------------- | +| One header, not six bands | Sort, sets and clear-all cost a tap behind the ellipsis sheet | +| Sets as a scrolling chip rail | Later sets sit off-screen until the rail is scrolled | +| One-line rows | No description, so near-identical forms are told apart by code and set | +| Pinned rows lead, with a real toggle | One 28px group label, which disappears when nothing is pinned | +| The shared composer stays the only input | The input is at the far end of the phone from the count it changes | + +A **weighted segment track** (the `DocumentSectionTrack` shape the in-page navigation template +prescribes) was tried first and dropped: eight sets across 390px leaves each segment about 48px, +under the width a set name needs, so the track degrades to unlabelled slivers. + +Nine phone frames cover library, one set selected, filtering, no matches, first run, item actions, +set management, partial load and signed out; one 1280px frame shows the desktop translation. + +**Content honesty.** Only `service | form | differential | therapy` are drawn, because +`favouriteContentTypeSchema` permits nothing else. The six earlier favourites mockups draw saved +medications, documents, quotes and searches, none of which has a content type and none of which can +be persisted. `tests/favourites-phone-perfected-mockups.test.ts` pins that, the controlled set +vocabulary, and the 48px tap knob. Differentials and therapies borrow `--tone-purple` / `--tone-indigo` +because the identity group has no `--type-differential` or `--type-therapy`; promotion would add them. + +Shared chrome is suppressed because every frame draws its own top bar, page header and composer. + +**Class-generation trap, again.** The phone frame's geometry (`max-w-phone-frame`, `h-phone-frame`, +`rounded-phone-frame`) and both desktop `grid-cols-[...]` tracks are pinned inline. Measured on this +route while building it, `--spacing-phone-frame` resolved to the empty string and the frame rendered +2661px tall with square corners; the desktop grid collapsed to a single stacked column. Same cause as +the `grid-cols-6` note above — Tailwind only emits a theme key some scanned source uses, and whether +that holds depends on what else lands in the sheet. Pin unusual geometry inline. + ## Dictionary — condensing the phone control row (2026-08-21) Runnable study at [`/mockups/dictionary-control-row`](../src/app/mockups/dictionary-control-row/page.tsx). diff --git a/src/app/mockups/favourites-phone-perfected/page.tsx b/src/app/mockups/favourites-phone-perfected/page.tsx new file mode 100644 index 0000000000..c05030b89b --- /dev/null +++ b/src/app/mockups/favourites-phone-perfected/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesPhonePerfectedMockupsPage } from "@/components/favourites-phone-perfected-mockups"; + +export const metadata: Metadata = { + title: "Favourites Phone Perfected Mockup - Clinical KB", + description: "Phone-first Favourites page mockup drawn across every signed-in state.", +}; + +export default function FavouritesPhonePerfectedMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 6149ceb96e..5d31598284 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -137,6 +137,13 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // so shared chrome would read as a second real header and a second search bar. const isFactsheetsTopicsPhoneMockup = pathname === "/mockups/factsheets-topics-phone"; + // Every frame draws the universal phone header, the page header and the + // docked composer inside itself, because measuring the chrome budget is the + // whole argument of the study. The `favourites-` prefix above already hides + // the shared composer; the shared header still has to go or it reads as a + // second real header above nine phone frames that each have their own. + const isFavouritesPhonePerfectedMockup = pathname === "/mockups/favourites-phone-perfected"; + return ( {children} diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx new file mode 100644 index 0000000000..40763c16e6 --- /dev/null +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx @@ -0,0 +1,543 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { + DesktopFrame, + FrameSheet, + PageHeader, + PhoneComposer, + PhoneFrame, + SetRail, + StatusBar, + UniversalHeader, + focusRing, +} from "./favourites-phone-shell"; +import { + FavouritesList, + ItemActionsSheetBody, + NoMatchesState, + NothingSavedState, + PageActionsSheetBody, + PartialLoadNotice, + SetsSheetBody, + SignedOutGate, +} from "./favourites-rows"; +import { + countsBySet, + favouriteRows, + kindIdentity, + matchesQuery, + pinnedFirst, + setLabels, + setOrder, + type FavouriteRow, + type FavouriteSetId, +} from "./fixtures"; + +/** + * Design scratch: Favourites on a phone, one perfected direction. + * + * WHAT THIS ARGUES + * + * `/favourites` is the page a signed-in user opens to get back to something + * they already decided they wanted. Measured on the dev server at 390x844: + * the first row of the saved list begins at y=1141, about 300px BELOW the + * fold, and each card is 228px tall. Above it sit an h1 and count, a hint + * strip, an in-flow composer, a privacy notice, a results band, a Continue + * card and a Recent card. None of the library itself is on the first screen. + * + * This design puts 165px of app chrome above the list and 72px rows under it: + * seven rows fully visible, an eighth partly. + * + * The argument here is one sentence: for a surface whose entire content is + * things you already chose, THE LIST IS THE PAGE, and a band earns its + * vertical space only by beating a row of favourites for it. + * + * FIVE DECISIONS + * + * 1. One header, not six bands. Title, live count, one ellipsis sheet. + * 2. Sets are the navigation, as a scrolling chip rail. The weighted segment + * track was tried and dropped - see `SetRail` for why. + * 3. A row costs one line: ~72px, nine on the first screen instead of two. + * 4. Pinning finally gets a control. `pinnedAt` has been in the schema and + * the PATCH contract with no UI anywhere. + * 5. The shared composer stays the only input. No search field in the header. + * + * WHAT IT REFUSES TO DRAW + * + * Saved medications, documents, quotes and searches, which the six existing + * favourites mockups all draw. `favouriteContentTypeSchema` allows exactly + * `service | form | differential | therapy`; the rest have no content type and + * cannot be persisted. Nothing here is wired to the account API and all copy + * is synthetic. + */ + +type FrameState = + "library" | "set" | "filtering" | "no-matches" | "empty" | "item-sheet" | "sets-sheet" | "partial" | "signed-out"; + +type SortMode = "recent" | "title" | "set"; + +const frames: ReadonlyArray<{ + id: FrameState; + number: string; + name: string; + summary: string; + cost: string; + note?: string; +}> = [ + { + id: "library", + number: "01", + name: "The library", + summary: + "32 saved items, four pinned. Seven rows sit fully above the fold and an eighth is partly visible, where the shipped page shows none of the library at all. The rail, the header and the composer are the entire chrome budget.", + cost: "A row carries no description, so two similarly named forms are told apart by their code and set rather than by a summary line.", + note: "interactive", + }, + { + id: "set", + number: "02", + name: "One set selected", + summary: + "Ward round, 7 items. The rail is the filter; the header count follows it. Pinned items inside the set still lead.", + cost: "Later sets sit off-screen until you scroll the rail. That is the price of keeping every set name legible.", + note: "interactive", + }, + { + id: "filtering", + number: "03", + name: "Filtering as you type", + summary: + "Typing in the shared composer filters in place. The header becomes a matched-of-total pair and the rail counts re-weight to the match, so no chip promises rows the search has already excluded.", + cost: "The composer is at the far end of the phone from the count it changes.", + }, + { + id: "no-matches", + number: "04", + name: "No matches", + summary: + "One empty state, rendered once, that says plainly this searched your saved items and not the whole library.", + cost: "The offer to search everywhere is a sentence rather than a button, so it is read rather than tapped.", + }, + { + id: "empty", + number: "05", + name: "Nothing saved yet", + summary: + "First run names the four things that can actually be saved. Somebody with an empty library cannot infer them, and no other surface tells them.", + cost: "It spends the whole first screen on explanation, which is only ever seen once.", + }, + { + id: "item-sheet", + number: "06", + name: "Item actions", + summary: + "Everything the card carried inline, plus the pin toggle the API has always supported and no screen has ever exposed. Four full-width 48px targets.", + cost: "Removing a favourite is now two taps rather than one. On a destructive action that is a gain.", + note: "interactive", + }, + { + id: "sets-sheet", + number: "07", + name: "Managing sets", + summary: + "Six controlled names with a database CHECK behind them, so this is a picker, not a text field. In-use and available are distinguished, and Unfiled is named rather than hidden.", + cost: "It cannot express a workflow the six names do not cover; renaming needs a schema change.", + }, + { + id: "partial", + number: "08", + name: "Partial load", + summary: + "Some favourites failed to fetch. The header reports what actually loaded, the notice says how many did not, and Retry is present. No fabricated zero.", + cost: "A warning band above the list is the one band that buys its space back, and only in this state.", + }, + { + id: "signed-out", + number: "09", + name: "Signed out", + summary: + "The boundary of the whole feature: `canAccessFavouritesMode` is demo mode or authenticated. Drawn once so the gate is designed rather than inherited.", + cost: "Nothing here hints at what is behind the gate beyond naming the four kinds.", + }, +]; + +/* ═══════════════════════ the screen ═══════════════════════ */ + +function sortRows(rows: readonly FavouriteRow[], sort: SortMode) { + const copy = [...rows]; + if (sort === "title") return copy.sort((a, b) => a.title.localeCompare(b.title)); + if (sort === "set") + return copy.sort((a, b) => setOrder.indexOf(a.setId) - setOrder.indexOf(b.setId) || a.recency - b.recency); + return copy.sort((a, b) => a.recency - b.recency); +} + +function FavouritesPhoneScreen({ state }: { state: FrameState }) { + const [activeSet, setActiveSet] = useState(state === "set" ? "ward-round" : "all"); + const [sort, setSort] = useState("recent"); + const [sheet, setSheet] = useState( + state === "item-sheet" ? "item" : state === "sets-sheet" ? "sets" : null, + ); + const [activeRow, setActiveRow] = useState(favouriteRows[1]); + + const query = state === "filtering" ? "ward" : state === "no-matches" ? "clozapine clinic" : ""; + const signedOut = state === "signed-out"; + const empty = state === "empty"; + const partial = state === "partial"; + + // A partial load is honest about what it holds: the rows it could not fetch + // are absent from the list AND from the count, never counted as loaded. + const loadedRows = useMemo(() => (partial ? favouriteRows.slice(0, 26) : favouriteRows), [partial]); + + const queryMatched = useMemo(() => loadedRows.filter((row) => matchesQuery(row, query)), [loadedRows, query]); + const visible = useMemo(() => { + const inSet = activeSet === "all" ? queryMatched : queryMatched.filter((row) => row.setId === activeSet); + return sort === "recent" ? pinnedFirst(inSet) : sortRows(inSet, sort); + }, [queryMatched, activeSet, sort]); + + // Rail counts reflect the search, so a chip never promises rows the query + // has already excluded. + const counts = useMemo(() => countsBySet(queryMatched), [queryMatched]); + + const rails = setOrder + .filter((id) => id === "all" || counts[id] > 0 || (!query && id === activeSet)) + .map((id) => ({ id, label: setLabels[id], count: counts[id] })); + + return ( + <> + + + + {signedOut || empty ? ( + <> + setSheet("page")} + /> +
+ {signedOut ? : } +
+ + ) : ( + <> + setSheet("page")} /> + setActiveSet(id as FavouriteSetId)} /> +
+ {partial ? : null} + {visible.length === 0 ? ( + + ) : ( + { + setActiveRow(row); + setSheet("item"); + }} + onOpenActions={(row) => { + setActiveRow(row); + setSheet("item"); + }} + /> + )} +
+ + )} + + + + {sheet === "item" ? ( + setSheet(null)} + > + setSheet(null)} /> + + ) : null} + + {sheet === "sets" ? ( + setSheet(null)}> + setSheet(null)} /> + + ) : null} + + {sheet === "page" ? ( + setSheet(null)}> + { + setSort(value); + setSheet(null); + }} + onOpenSets={() => setSheet("sets")} + /> + + ) : null} + + ); +} + +/* ═══════════════════════ desktop reference ═══════════════════════ */ + +/** + * One frame, not a second study. The phone design opens out rather than + * changing: the rail unrolls into a left list, the metadata line spreads into + * columns, and the width that a phone does not have becomes the detail panel + * `/favourites` already earns at `xl:`. + */ +function DesktopReference() { + const counts = countsBySet(favouriteRows); + const rows = pinnedFirst(favouriteRows).slice(0, 9); + const selected = rows[0]; + const identity = kindIdentity[selected.kind]; + + return ( +
+ + +
+
+

Favourites

+ {favouriteRows.length} saved +
+
    + {rows.map((row) => { + const rowIdentity = kindIdentity[row.kind]; + const Glyph = rowIdentity.icon; + const isSelected = row.id === selected.id; + return ( +
  • + +
  • + ); + })} +
+
+ + +
+ ); +} + +/* ═══════════════════════ the page ═══════════════════════ */ + +const decisions: ReadonlyArray<{ n: string; head: string; body: string }> = [ + { + n: "1", + head: "One header, not six bands", + body: "Title, live count, one ellipsis sheet. Sort, set management and clear-all move behind it. Everything above the list — app header, page header and set rail — measures 165px.", + }, + { + n: "2", + head: "Sets become the navigation", + body: "A scrolling chip rail carrying every set name and its count. A weighted segment track was tried first and dropped: eight sets across 390px leaves each about 48px, under the width a set name needs.", + }, + { + n: "3", + head: "A row costs one line", + body: "Glyph, title, one metadata line, one trailing control. 72px against the shipped card's measured 228px, and both targets clear 44px.", + }, + { + n: "4", + head: "Pinning finally gets a control", + body: "`pinnedAt` has been in the schema and the PATCH contract since August with no UI anywhere. Pinned rows lead every view under one label that disappears when nothing is pinned.", + }, + { + n: "5", + head: "The composer stays the only input", + body: "Typing filters in place; the count becomes a matched-of-total pair and the rail counts re-weight. No header search field. The six existing favourites mockups each draw a second search bar, which the one-composer contract forbids.", + }, +]; + +export function FavouritesPhonePerfectedMockupsPage() { + return ( +
+
+
+

+ Favourites · signed in · phone +

+

+ The list is the page +

+

+ Favourites is where you go to get back to something you already chose. Measured at 390 × 844, the shipped + page puts the first row of your saved list at y = 1141 — about 300px below the fold — + behind a hint strip, a composer, a privacy notice, a results band, a Continue card and a Recent card, and + then spends 228px on each item. Nothing of the library is on the first screen. This + direction spends 165px of chrome and 72px a row, which puts seven items + above the fold, and it is drawn across every state that actually occurs. +

+ +
    + {decisions.map((decision) => ( +
  1. +

    + {decision.n} +

    +

    {decision.head}

    +

    {decision.body}

    +
  2. + ))} +
+ +

+ How the numbers were taken — + Chromium at a 390 × 844 viewport against the local dev server, reading + getBoundingClientRect() on the live{" "} + /favourites route and on these frames. The 1141px figure includes a + demo-mode hint strip a signed-in user does not see, worth roughly 40px; it is below the fold either way. +

+ +

+ Only four things can be saved.{" "} + favouriteContentTypeSchema allows{" "} + service, form, differential, therapy. + The six existing favourites mockups draw saved medications, documents, quotes and searches; none of those + has a content type, so none can be persisted. Nothing here is wired to the account API and every row is + synthetic — not clinical content. +

+
+
+ +
+ {frames.map((frame) => ( +
+
+

+ {frame.number} +

+

{frame.name}

+
+

{frame.summary}

+

+ Cost — + {frame.cost} +

+
+ + + +
+
+ ))} +
+ +
+
+
+

D

+

Desktop reference

+
+

+ One frame, not a second study. The design opens out rather than changing shape: the rail unrolls into a left + list, the metadata line spreads into columns, and the width a phone does not have becomes the detail panel{" "} + /favourites already earns at xl:. +

+
+ + + +
+
+
+
+ ); +} diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx new file mode 100644 index 0000000000..cb6791d416 --- /dev/null +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { ChevronDown, Menu, MoreHorizontal, Plus, Search, Star } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/components/ui-primitives"; + +/** + * The device chrome for the phone Favourites study. + * + * Everything here is hand-drawn rather than imported. `InPageNavHeader` + * (`src/components/in-page-nav/in-page-nav-header.tsx`) is the production + * template this header follows, but it wraps itself in + * `PhoneHeaderCollapsePortal`, which portals into the real universal header's + * slot. Inside a mockup phone frame that slot does not exist, so importing the + * component would teleport the header out of the frame it is meant to sit in. + */ + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +export { focusRing }; + +/* ═══════════════════════════ frame ═══════════════════════════ */ + +/** + * Geometry is pinned inline rather than through `max-w-phone-frame`, + * `h-phone-frame` and `rounded-phone-frame`. + * + * Those three utilities read `--container-phone-frame`, `--spacing-phone-frame` + * and `--radius-phone-frame` from the `@theme` block in `globals.css`, and + * Tailwind only emits a theme key some scanned source actually uses. Measured + * on this route while building it, all three were absent: `max-width` computed + * to `none`, `--spacing-phone-frame` to the empty string, and the frame + * rendered 2661px tall with square corners. Whether they resolve depends on + * what else happens to be compiled into the same sheet, which is not a + * property this file can rely on -- `mockups/README.md` records the same trap + * against a bare `grid-cols-6`. Values are 390 x 844 and a 1.85rem radius, + * verbatim from those tokens. + */ +const PHONE_FRAME = { width: "24.375rem", height: "52.75rem", radius: "1.85rem" } as const; + +export function PhoneFrame({ label, note, children }: { label: string; note?: string; children: ReactNode }) { + return ( +
+
+ {label} + {note ? {note} : null} +
+
+ {children} +
+
+ ); +} + +export function StatusBar() { + return ( +
+ 9:41 + + + + + +
+ ); +} + +/* ═══════════════════════════ chrome ═══════════════════════════ */ + +/** The universal app header every phone route already carries: menu, the mode + * pill, new chat. Drawn so the page header below it can be measured honestly + * against the space it actually has. */ +export function UniversalHeader() { + return ( +
+ + + +
+ ); +} + +/** + * The page header: title, live count, one ellipsis sheet. Nothing else. + * + * `count` is the honest pair — matched and total — because a filtered library + * that reports only one number is the defect `#091` closed on the results band. + */ +export function PageHeader({ + matched, + total, + onOpenActions, + statusNote, +}: { + matched: number; + total: number; + onOpenActions: () => void; + statusNote?: string; +}) { + const filtered = matched !== total; + return ( +
+
+

+ Favourites +

+

+ {statusNote ?? (filtered ? `${matched} of ${total} saved` : `${total} saved`)} +

+
+ +
+ ); +} + +/** + * The set rail. + * + * A weighted segment track — the shape `DocumentSectionTrack` draws — was the + * first attempt and was dropped: eight sets across 390px gives each segment + * about 48px, which is under the width a set name needs, so the track degrades + * to eight unlabelled slivers and the labels move into a sheet nobody opens. + * A scrolling chip rail keeps every set name and its count legible, and pays + * for it by putting the later sets off-screen until you scroll. + */ +export function SetRail({ + sets, + activeId, + onSelect, +}: { + sets: ReadonlyArray<{ id: string; label: string; count: number }>; + activeId: string; + onSelect: (id: string) => void; +}) { + return ( +
+
+ {sets.map((set) => { + const active = set.id === activeId; + return ( + + ); + })} +
+
+ ); +} + +/* ═══════════════════════════ composer ═══════════════════════════ */ + +/** + * The shared bottom composer, edge-to-edge and painting its own home-indicator + * region. There is no search field in the header: the composer is the page's + * only input, and typing into it filters the list in place — which is already + * how `/favourites` behaves through `useSearchCommand`. + */ +export function PhoneComposer({ query = "" }: { query?: string }) { + return ( +
+
+ + + {query || "Search favourites..."} + + {query ? : null} + +
+
+ ); +} + +/* ═══════════════════════════ in-frame sheet ═══════════════════════════ */ + +/** + * A bottom sheet drawn inside the frame. The production `Sheet` + * (`src/components/ui/sheet.tsx`) portals to the overlay root, which would + * escape the phone frame, so this is the frame-local stand-in. The geometry — + * rounded-xl top corners, a grab handle, a titled header, a bordered footer — + * matches what `Sheet` renders on a phone. + */ +export function FrameSheet({ + title, + description, + onClose, + children, +}: { + title: string; + description?: string; + onClose: () => void; + children: ReactNode; +}) { + return ( +
+ + +
+ + + + + + + {row.title} + + + {showPinGlyph && row.pinned ? ( + + ) : null} + + {identity.label} + + + · + + {row.detail} + + · + + {row.lastOpened} + + + + +
+ + ); +} + +function GroupLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** + * Pinned rows lead every view under one label, then the rest follow. + * + * `pinnedAt` has been in the schema since `20260823090000_user_favourite_sets` + * and in the `PATCH setPinned` contract, with no control anywhere in the app. + * This is the control. It costs one 24px label, and when nothing is pinned the + * label does not render, so an unpinned library pays nothing for it. + */ +export function FavouritesList({ + rows, + showPinnedGroup, + onOpen, + onOpenActions, +}: { + rows: readonly FavouriteRow[]; + showPinnedGroup: boolean; + onOpen: (row: FavouriteRow) => void; + onOpenActions: (row: FavouriteRow) => void; +}) { + const pinned = showPinnedGroup ? rows.filter((row) => row.pinned) : []; + const rest = showPinnedGroup ? rows.filter((row) => !row.pinned) : rows; + + return ( +
+ {pinned.length > 0 ? ( + <> + Pinned +
    + {pinned.map((row) => ( + + ))} +
+ + ) : null} + + {rest.length > 0 ? ( + <> + {pinned.length > 0 ? Everything else : null} +
    + {rest.map((row) => ( + + ))} +
+ + ) : null} +
+ ); +} + +/* ═══════════════════════════ states ═══════════════════════════ */ + +/** One no-match state, rendered once. Three simultaneous live regions was the + * defect `#225` closed; the shape it settled on is one. */ +export function NoMatchesState({ query, total }: { query: string; total: number }) { + return ( +
+ undefined} + className={cn( + "inline-flex min-h-12 items-center gap-2 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-4 text-sm-minus font-bold text-[color:var(--clinical-accent)]", + focusRing, + )} + > + Clear search + + } + /> +
+ ); +} + +/** + * First run. The version worth building is the one that answers "what is this + * page for" without a marketing lockup: name the four things that can be + * saved, because a user who has saved nothing cannot infer them, and give one + * route out. + */ +export function NothingSavedState() { + return ( +
+ +
    + {(["service", "form", "differential", "therapy"] as const).map((kind) => { + const identity = kindIdentity[kind]; + const Glyph = identity.icon; + return ( +
  • + + + + {identity.plural} +
  • + ); + })} +
+
+ +
+
+ ); +} + +/** + * Partial load. `foldSavedFavouritesStatus` can return `partial`, and the rule + * the results band settled on (`#091`) is that a partial failure reports the + * honest loaded count and an explicit retry — never a fabricated zero and + * never a total that includes rows it could not fetch. + */ +export function PartialLoadNotice({ failed }: { failed: number }) { + return ( +
+
+ +
+

+ {failed} favourites could not be loaded +

+

+ The count above is what actually loaded. Nothing has been removed. +

+
+ +
+
+ ); +} + +/** The gate. `canAccessFavouritesMode` is `demoMode || authenticated`, so this + * is the boundary of "for logged-in users" and worth drawing once. */ +export function SignedOutGate() { + return ( +
+ undefined} + className={cn( + "inline-flex min-h-12 items-center gap-2 rounded-lg bg-[color:var(--command)] px-4 text-sm-minus font-bold text-[color:var(--command-contrast)]", + focusRing, + )} + > + Sign in + + } + /> +
+ ); +} + +/* ═══════════════════════════ sheet bodies ═══════════════════════════ */ + +function SheetRow({ + icon: Glyph, + label, + hint, + tone = "default", + trailing, + onClick, +}: { + icon: typeof Pin; + label: string; + hint?: string; + tone?: "default" | "danger"; + trailing?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +/** Everything the production card carried inline, and the pin toggle the API + * has always supported. Four rows, each a full-width 48px target. */ +export function ItemActionsSheetBody({ row, onClose }: { row: FavouriteRow; onClose: () => void }) { + return ( +
+ + + + +
+ ); +} + +/** + * Set management. The six names are a controlled vocabulary + * (`favouriteSetNames`) with a database CHECK behind them, so this sheet is a + * picker and not a text field — showing which are already in use and which are + * still available is the whole job. + */ +export function SetsSheetBody({ counts, onClose }: { counts: Record; onClose: () => void }) { + const controlled = setOrder.filter((id) => id !== "all" && id !== "unfiled"); + return ( +
+ {controlled.map((id) => { + const count = counts[id]; + const inUse = count > 0; + return ( + + ); + })} +

+ Set names are limited to these six approved clinical workflows. Items you have not filed stay in Unfiled ( + {counts.unfiled}). +

+
+ ); +} + +/** The page-level sheet: sort, sets, and the destructive action, which belongs + * behind a sheet rather than on the header where it can be hit by accident. */ +export function PageActionsSheetBody({ + sort, + onSelectSort, + onOpenSets, +}: { + sort: "recent" | "title" | "set"; + onSelectSort: (value: "recent" | "title" | "set") => void; + onOpenSets: () => void; +}) { + const options: ReadonlyArray<{ value: "recent" | "title" | "set"; label: string; hint: string }> = [ + { value: "recent", label: "Recently opened", hint: "Default" }, + { value: "title", label: "Title, A to Z", hint: "" }, + { value: "set", label: "Grouped by set", hint: "" }, + ]; + return ( +
+

+ Sort +

+ {options.map((option) => ( + + ) : undefined + } + onClick={() => onSelectSort(option.value)} + /> + ))} +

+ Library +

+ + +
+ ); +} diff --git a/src/components/favourites-phone-perfected-mockups/fixtures.ts b/src/components/favourites-phone-perfected-mockups/fixtures.ts new file mode 100644 index 0000000000..aabf8ea723 --- /dev/null +++ b/src/components/favourites-phone-perfected-mockups/fixtures.ts @@ -0,0 +1,441 @@ +import { ClipboardList, MessagesSquare, Split, Stethoscope, type LucideIcon } from "lucide-react"; + +/** + * Synthetic fixture for the phone Favourites study. Not clinical content and + * not patient data: titles are drawn from the repository's own catalogues + * (`data/forms-page-snapshot.json`, `data/differentials-snapshot.json`, + * `src/data/therapies-index.json`) so the rows measure like real ones, and the + * service names are plausible Perth-service labels invented for this page. + * + * The four kinds below are the ONLY things this application can persist as a + * favourite — `favouriteContentTypeSchema` in `src/lib/favourites-contract.ts` + * is `["service", "form", "differential", "therapy"]`. The six existing + * favourites mockups draw saved medications, documents, quotes and searches; + * none of those has a content type, so none of them can be saved. + */ + +export type FavouriteKind = "service" | "form" | "differential" | "therapy"; + +/** `favouriteSetNames` verbatim, plus the null-set bucket the schema allows + * (`user_favourites.set_id` is nullable) and an `all` pseudo-set. */ +export type FavouriteSetId = + "all" | "clinical-review" | "ward-round" | "on-call" | "follow-up" | "teaching" | "reference" | "unfiled"; + +export type FavouriteRow = { + id: string; + kind: FavouriteKind; + title: string; + /** The single qualifier that survives the move from card to row. */ + detail: string; + setId: Exclude; + pinned?: boolean; + /** Presentation string in the shape `formatLastOpened` already produces. */ + lastOpened: string; + /** Lower is more recent. Drives the "Recent" sort without inventing clocks. */ + recency: number; +}; + +export const kindIdentity: Record< + FavouriteKind, + { label: string; plural: string; icon: LucideIcon; ink: string; soft: string; border: string } +> = { + // Services and forms carry real identity tokens. Differentials and therapies + // do not, so they borrow within-surface category tones — promotion would want + // `--type-differential` and `--type-therapy` added to the identity group. + service: { + label: "Service", + plural: "Services", + icon: Stethoscope, + ink: "var(--type-service)", + soft: "var(--type-service-soft)", + border: "var(--type-service-border)", + }, + form: { + label: "Form", + plural: "Forms", + icon: ClipboardList, + ink: "var(--type-form)", + soft: "var(--type-form-soft)", + border: "var(--type-form-border)", + }, + differential: { + label: "Differential", + plural: "Differentials", + icon: Split, + ink: "var(--tone-purple)", + soft: "var(--tone-purple-soft)", + border: "var(--tone-purple-border)", + }, + therapy: { + label: "Therapy", + plural: "Therapies", + icon: MessagesSquare, + ink: "var(--tone-indigo)", + soft: "var(--tone-indigo-soft)", + border: "var(--tone-indigo-border)", + }, +}; + +export const setLabels: Record = { + all: "All", + "clinical-review": "Clinical review", + "ward-round": "Ward round", + "on-call": "On call", + "follow-up": "Follow up", + teaching: "Teaching", + reference: "Reference", + unfiled: "Unfiled", +}; + +/** Rail order. `all` leads, `unfiled` trails, the six controlled names sit + * between them in the order `favouriteSetNames` declares. */ +export const setOrder: FavouriteSetId[] = [ + "all", + "clinical-review", + "ward-round", + "on-call", + "follow-up", + "teaching", + "reference", + "unfiled", +]; + +export const favouriteRows: FavouriteRow[] = [ + // ---- pinned ---------------------------------------------------------- + { + id: "form-1a", + kind: "form", + title: "Referral for examination by psychiatrist", + detail: "Form 1A", + setId: "on-call", + pinned: true, + lastOpened: "Today 08:44", + recency: 1, + }, + { + id: "diff-delirium", + kind: "differential", + title: "Delirium / acute confusion / encephalopathy", + detail: "Must-not-miss", + setId: "ward-round", + pinned: true, + lastOpened: "Today 08:20", + recency: 2, + }, + { + id: "form-cto", + kind: "form", + title: "Community treatment order", + detail: "Form 5A", + setId: "follow-up", + pinned: true, + lastOpened: "Yesterday", + recency: 3, + }, + { + id: "svc-cmht-inner", + kind: "service", + title: "Inner City Community Mental Health Team", + detail: "Adult · catchment intake", + setId: "ward-round", + pinned: true, + lastOpened: "Yesterday", + recency: 4, + }, + + // ---- ward round ------------------------------------------------------ + { + id: "diff-clozapine-myocarditis", + kind: "differential", + title: "Clozapine-induced myocarditis", + detail: "Must-not-miss", + setId: "ward-round", + lastOpened: "Today 07:55", + recency: 5, + }, + { + id: "diff-nms", + kind: "differential", + title: "Neuroleptic malignant syndrome", + detail: "Must-not-miss", + setId: "ward-round", + lastOpened: "2 days ago", + recency: 12, + }, + { + id: "form-continuation", + kind: "form", + title: "Continuation of detention", + detail: "Form 3B", + setId: "ward-round", + lastOpened: "2 days ago", + recency: 13, + }, + { + id: "diff-serotonin", + kind: "differential", + title: "Serotonin syndrome", + detail: "Must-not-miss", + setId: "ward-round", + lastOpened: "3 days ago", + recency: 18, + }, + { + id: "svc-liaison", + kind: "service", + title: "Consultation Liaison Psychiatry, Royal Perth", + detail: "Inpatient referral", + setId: "ward-round", + lastOpened: "Last week", + recency: 24, + }, + + // ---- on call --------------------------------------------------------- + { + id: "form-transport", + kind: "form", + title: "Transport order", + detail: "Form 6A", + setId: "on-call", + lastOpened: "Today 02:10", + recency: 6, + }, + { + id: "form-detain-voluntary", + kind: "form", + title: "Detain voluntary inpatient for assessment", + detail: "Form 2A", + setId: "on-call", + lastOpened: "Yesterday", + recency: 8, + }, + { + id: "diff-alcohol-withdrawal", + kind: "differential", + title: "Alcohol withdrawal", + detail: "Time-critical", + setId: "on-call", + lastOpened: "Yesterday", + recency: 9, + }, + { + id: "diff-acute-dystonia", + kind: "differential", + title: "Acute dystonia", + detail: "Treat now", + setId: "on-call", + lastOpened: "3 days ago", + recency: 19, + }, + { + id: "svc-triage", + kind: "service", + title: "Mental Health Emergency Response Line", + detail: "Statewide · 24 hour", + setId: "on-call", + lastOpened: "Last week", + recency: 25, + }, + { + id: "form-transport-extension", + kind: "form", + title: "Extension of transport order", + detail: "Form 6B", + setId: "on-call", + lastOpened: "Last week", + recency: 26, + }, + + // ---- clinical review ------------------------------------------------- + { + id: "diff-treatment-resistant", + kind: "differential", + title: "Treatment-resistant depression", + detail: "Review pathway", + setId: "clinical-review", + lastOpened: "Today 09:02", + recency: 7, + }, + { + id: "thx-cbt", + kind: "therapy", + title: "Cognitive Behavioural Therapy (CBT)", + detail: "First line · depression", + setId: "clinical-review", + lastOpened: "Yesterday", + recency: 10, + }, + { + id: "diff-akathisia", + kind: "differential", + title: "Akathisia", + detail: "Adverse effect", + setId: "clinical-review", + lastOpened: "2 days ago", + recency: 14, + }, + { + id: "diff-adverse-nonadherence", + kind: "differential", + title: "Adverse-effect-driven non-adherence", + detail: "Review pathway", + setId: "clinical-review", + lastOpened: "4 days ago", + recency: 20, + }, + { + id: "thx-adherence", + kind: "therapy", + title: "Adherence-focused brief work", + detail: "Adjunct", + setId: "clinical-review", + lastOpened: "Last week", + recency: 27, + }, + + // ---- reference ------------------------------------------------------- + { + id: "form-order-cannot-detain", + kind: "form", + title: "Order that person cannot continue to be detained", + detail: "Form 4B", + setId: "reference", + lastOpened: "2 days ago", + recency: 15, + }, + { + id: "diff-anticholinergic", + kind: "differential", + title: "Anticholinergic burden", + detail: "Prescribing check", + setId: "reference", + lastOpened: "5 days ago", + recency: 21, + }, + { + id: "diff-antidepressant-discontinuation", + kind: "differential", + title: "Antidepressant discontinuation syndrome", + detail: "Prescribing check", + setId: "reference", + lastOpened: "Last week", + recency: 28, + }, + { + id: "form-transfer", + kind: "form", + title: "Transfer order", + detail: "Form 7A", + setId: "reference", + lastOpened: "Last week", + recency: 29, + }, + { + id: "diff-alcohol-brain-injury", + kind: "differential", + title: "Alcohol-related brain injury", + detail: "Longer-term", + setId: "reference", + lastOpened: "Saved", + recency: 34, + }, + + // ---- teaching -------------------------------------------------------- + { + id: "thx-act", + kind: "therapy", + title: "Acceptance and Commitment Therapy (ACT)", + detail: "Registrar teaching", + setId: "teaching", + lastOpened: "3 days ago", + recency: 16, + }, + { + id: "thx-behavioural-activation", + kind: "therapy", + title: "Behavioural Activation (BA)", + detail: "Registrar teaching", + setId: "teaching", + lastOpened: "Last week", + recency: 22, + }, + { + id: "thx-family", + kind: "therapy", + title: "Behavioural Couples Therapy (BCT)", + detail: "Registrar teaching", + setId: "teaching", + lastOpened: "Last week", + recency: 30, + }, + { + id: "thx-sscm", + kind: "therapy", + title: "Specialist Supportive Clinical Management (SSCM)", + detail: "Eating disorders", + setId: "teaching", + lastOpened: "Saved", + recency: 35, + }, + + // ---- follow up ------------------------------------------------------- + { + id: "form-cto-continuation", + kind: "form", + title: "Continuation of community treatment order", + detail: "Form 5B", + setId: "follow-up", + lastOpened: "3 days ago", + recency: 17, + }, + + // ---- unfiled --------------------------------------------------------- + { + id: "svc-headspace", + kind: "service", + title: "Youth Early Psychosis Service, Northern Suburbs", + detail: "16-25 · self-referral", + setId: "unfiled", + lastOpened: "5 days ago", + recency: 23, + }, + { + id: "thx-craft", + kind: "therapy", + title: "Community Reinforcement and Family Training (CRAFT)", + detail: "Family work", + setId: "unfiled", + lastOpened: "Saved", + recency: 36, + }, +]; + +/** Counts per rail chip, computed once so the rail and the header agree. */ +export function countsBySet(rows: readonly FavouriteRow[]): Record { + const counts = Object.fromEntries(setOrder.map((id) => [id, 0])) as Record; + for (const row of rows) { + counts.all += 1; + counts[row.setId] += 1; + } + return counts; +} + +/** Pinned first, then the caller's order. Pinning is the only reordering a + * phone user can reach; `sortOrder` up/down stays a desktop affordance. */ +export function pinnedFirst(rows: readonly FavouriteRow[]): FavouriteRow[] { + return [...rows].sort((a, b) => { + if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1; + return a.recency - b.recency; + }); +} + +export function matchesQuery(row: FavouriteRow, query: string) { + if (!query.trim()) return true; + const needle = query.trim().toLowerCase(); + return ( + row.title.toLowerCase().includes(needle) || + row.detail.toLowerCase().includes(needle) || + kindIdentity[row.kind].label.toLowerCase().includes(needle) || + setLabels[row.setId].toLowerCase().includes(needle) + ); +} diff --git a/src/components/favourites-phone-perfected-mockups/index.ts b/src/components/favourites-phone-perfected-mockups/index.ts new file mode 100644 index 0000000000..2d846b9281 --- /dev/null +++ b/src/components/favourites-phone-perfected-mockups/index.ts @@ -0,0 +1 @@ +export { FavouritesPhonePerfectedMockupsPage } from "./favourites-phone-perfected-page"; diff --git a/tests/favourites-phone-perfected-mockups.test.ts b/tests/favourites-phone-perfected-mockups.test.ts new file mode 100644 index 0000000000..a0f560ead2 --- /dev/null +++ b/tests/favourites-phone-perfected-mockups.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { favouriteContentTypeSchema, favouriteSetNames } from "@/lib/favourites-contract"; + +import { favouriteRows, kindIdentity, setLabels } from "@/components/favourites-phone-perfected-mockups/fixtures"; + +/** + * The phone Favourites mockup is design scratch and deliberately untested for + * layout — it will churn. Two things about it are not layout. + * + * The first is the decision this study exists to correct: the six favourites + * mockups already on main draw saved medications, documents, quotes and + * searches, none of which `favouriteContentTypeSchema` permits, so they design + * against a data layer that does not exist. If this fixture ever drifts back + * to fiction the study loses the only thing that distinguishes it. + * + * The second is the set vocabulary, which has a database CHECK behind it + * (`20260823090000_user_favourite_sets.sql`). A mockup that invents a seventh + * set name is proposing a migration without saying so. + */ + +const root = join(__dirname, "..", "src", "components", "favourites-phone-perfected-mockups"); + +function source(file: string) { + return readFileSync(join(root, file), "utf8"); +} + +describe("favourites phone-perfected mockup", () => { + it("draws only the four content types the favourites contract can persist", () => { + const allowed = favouriteContentTypeSchema.options; + + expect(Object.keys(kindIdentity).sort()).toEqual([...allowed].sort()); + for (const row of favouriteRows) { + expect(allowed).toContain(row.kind); + } + }); + + it("does not reintroduce the favourite kinds the data layer cannot save", () => { + const files = [ + "fixtures.ts", + "favourites-rows.tsx", + "favourites-phone-shell.tsx", + "favourites-phone-perfected-page.tsx", + ]; + // Each of these appears as a drawn favourite kind in the existing + // favourites mockups and has no `contentType`, so it can never be saved. + const fictional = ["Saved search", "Quote"]; + + for (const file of files) { + const text = source(file); + for (const kind of fictional) { + // The page's own explanatory copy names them to say they are excluded, + // so only the fixture and the rendering components are held to this. + if (file === "favourites-phone-perfected-page.tsx") continue; + expect(text, `${file} must not draw a "${kind}" favourite`).not.toContain(kind); + } + } + }); + + it("uses the controlled set vocabulary and nothing else", () => { + // `user_favourites.set_id` is nullable, so "Unfiled" is a real bucket + // rather than a seventh set name. Every FILED row must use a controlled + // name; only the null bucket is allowed to sit outside the vocabulary. + const filed = favouriteRows.filter((row) => row.setId !== "unfiled"); + expect(filed.length).toBeGreaterThan(0); + for (const label of new Set(filed.map((row) => setLabels[row.setId]))) { + expect([...favouriteSetNames]).toContain(label); + } + + // Every controlled name is reachable from the rail, including the ones the + // fixture leaves empty — an unused set that vanishes cannot be filed into. + for (const name of favouriteSetNames) { + expect(Object.values(setLabels)).toContain(name); + } + }); + + it("keeps clinical-state colour off decoration", () => { + // `--danger` / `--warning` / `--success` are the clinical-state layer. + // The only sanctioned uses here are the destructive action and the + // partial-load failure notice, both in favourites-rows.tsx. + for (const file of ["fixtures.ts", "favourites-phone-shell.tsx", "favourites-phone-perfected-page.tsx"]) { + const text = source(file); + expect(text, `${file} must not paint with clinical-state colour`).not.toMatch( + /var\(--(?:danger|warning|success)(?:-[a-z]+)?\)/, + ); + } + }); + + it("keeps every phone tap target at the production 48px knob", () => { + // Mockups are gate-exempt and may use min-h-11, but this one is authored + // to be promotable, so it never drops below the production knob. + for (const file of ["favourites-rows.tsx", "favourites-phone-perfected-page.tsx"]) { + expect(source(file), `${file} must not use the 44px mockup target`).not.toMatch(/\bmin-h-11\b/); + } + }); +}); From a230afaf506eb37db25fb1c2b19b8c4f024b77d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 23:41:47 +0000 Subject: [PATCH 02/12] Favourites mockup: keep Continue, drop Recent, and offer the type chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback on the first pass: the Continue card and the Recent card from the shipped page are worth keeping. Measured at 390px, the three derived cards above the list are Continue 113px, Recent 277px and Your sets 255px — 645px of the 1141px before the first saved row. They are not one decision, so they are not treated as one. Continue returns, rebuilt so the strip IS the button rather than stacking a full-width button under a title: 72px, exactly one row, pinned above the list so it does not scroll away. It answers a different question from the list beneath it — what was I in the middle of, not what have I saved. Cost, measured: six saved rows above the fold instead of seven. Frame 10 draws the same library without it so the trade is visible rather than argued. Recent does not return. It costs 277px to show the three most recently opened items, and the list beneath it is already sorted by recency, so those are its first three rows. The resumed item is drawn once. The shipped page shows it in Continue, again in Recent and again in the table; lifting it out of the list keeps one 72px row from being spent twice on the same thing. Frame 11 offers the shipped Recent card's type pill in place of the coloured type word. It scans faster down a column of mixed kinds, which is what that card got right, and costs about 14px of the metadata line. Two defects found while wiring this up and fixed: counting matches after Continue was lifted out made an unfiltered library read "31 of 32 saved" while the All chip still said 32, so the count is now taken before the lift; and the Continue strip's icon was missing aria-hidden, which require-lucide-icon-aria caught. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QtKvCY1d3cGd5EBvGNQouv --- mockups/README.md | 30 +++++-- .../favourites-phone-perfected-page.tsx | 87 ++++++++++++++++--- .../favourites-rows.tsx | 87 +++++++++++++++++-- 3 files changed, 179 insertions(+), 25 deletions(-) diff --git a/mockups/README.md b/mockups/README.md index 8a85f2fcac..61486e6951 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -111,20 +111,32 @@ notice, a results band, a Continue card and a Recent card. Each item card is **2 the library itself is on the first screen. This direction spends **165px** of app chrome and **72px** a row, which puts **seven rows fully above the fold and an eighth partly**. -| Decision | Trade-off | -| ---------------------------------------- | ---------------------------------------------------------------------- | -| One header, not six bands | Sort, sets and clear-all cost a tap behind the ellipsis sheet | -| Sets as a scrolling chip rail | Later sets sit off-screen until the rail is scrolled | -| One-line rows | No description, so near-identical forms are told apart by code and set | -| Pinned rows lead, with a real toggle | One 28px group label, which disappears when nothing is pinned | -| The shared composer stays the only input | The input is at the far end of the phone from the count it changes | +| Decision | Trade-off | +| ------------------------------------------ | -------------------------------------------------------------------------- | +| One header, not six bands | Sort, sets and clear-all cost a tap behind the ellipsis sheet | +| Sets as a scrolling chip rail | Later sets sit off-screen until the rail is scrolled | +| One-line rows | No description, so near-identical forms are told apart by code and set | +| Pinned rows lead, with a real toggle | One 28px group label, which disappears when nothing is pinned | +| The shared composer stays the only input | The input is at the far end of the phone from the count it changes | +| Continue survives at 72px; Recent does not | Continue costs exactly one saved row (six above the fold instead of seven) | A **weighted segment track** (the `DocumentSectionTrack` shape the in-page navigation template prescribes) was tried first and dropped: eight sets across 390px leaves each segment about 48px, under the width a set name needs, so the track degrades to unlabelled slivers. -Nine phone frames cover library, one set selected, filtering, no matches, first run, item actions, -set management, partial load and signed out; one 1280px frame shows the desktop translation. +**What survived from the shipped page.** Measured at 390px, the three derived cards above the list are +Continue 113px, Recent 277px and Your sets 255px — 645px of the 1141px. Continue is kept because it +answers a different question from the list ("what was I in the middle of", not "what have I saved"), +rebuilt so the strip _is_ the button rather than stacking a button under a title: 72px, exactly one +row, and it does not scroll away. The resumed item is then drawn once — the shipped page shows it in +Continue, again in Recent and again in the table. Recent is dropped: the list below it is already +sorted by recency, so its three rows are the first three rows. Your sets is replaced by the rail, +which is always visible for 53px instead of 255px. + +Eleven phone frames cover library, one set selected, filtering, no matches, first run, item actions, +set management, partial load, signed out, and two comparisons — the library without Continue, and the +type shown as the shipped Recent card's pill instead of a coloured word. One 1280px frame shows the +desktop translation. **Content honesty.** Only `service | form | differential | therapy` are drawn, because `favouriteContentTypeSchema` permits nothing else. The six earlier favourites mockups draw saved diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx index 40763c16e6..8ce7053176 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx @@ -16,6 +16,7 @@ import { focusRing, } from "./favourites-phone-shell"; import { + ContinueStrip, FavouritesList, ItemActionsSheetBody, NoMatchesState, @@ -49,8 +50,10 @@ import { * strip, an in-flow composer, a privacy notice, a results band, a Continue * card and a Recent card. None of the library itself is on the first screen. * - * This design puts 165px of app chrome above the list and 72px rows under it: - * seven rows fully visible, an eighth partly. + * This design puts 165px of app chrome above the list and 72px rows under it. + * Measured: the Continue strip plus six saved rows sit fully above the fold + * and a seventh is partly visible; with Continue removed it is seven rows. + * The strip costs exactly one row. * * The argument here is one sentence: for a surface whose entire content is * things you already chose, THE LIST IS THE PAGE, and a band earns its @@ -61,10 +64,13 @@ import { * 1. One header, not six bands. Title, live count, one ellipsis sheet. * 2. Sets are the navigation, as a scrolling chip rail. The weighted segment * track was tried and dropped - see `SetRail` for why. - * 3. A row costs one line: ~72px, nine on the first screen instead of two. + * 3. A row costs one line: 72px against the shipped card's measured 228px. * 4. Pinning finally gets a control. `pinnedAt` has been in the schema and * the PATCH contract with no UI anywhere. * 5. The shared composer stays the only input. No search field in the header. + * 6. Continue survives from the shipped page, at 72px instead of 113px. The + * Recent card does not: it costs 277px to show three items the + * recency-sorted list already has as its first three rows. * * WHAT IT REFUSES TO DRAW * @@ -76,7 +82,17 @@ import { */ type FrameState = - "library" | "set" | "filtering" | "no-matches" | "empty" | "item-sheet" | "sets-sheet" | "partial" | "signed-out"; + | "library" + | "set" + | "filtering" + | "no-matches" + | "empty" + | "item-sheet" + | "sets-sheet" + | "partial" + | "signed-out" + | "no-continue" + | "type-chips"; type SortMode = "recent" | "title" | "set"; @@ -93,7 +109,7 @@ const frames: ReadonlyArray<{ number: "01", name: "The library", summary: - "32 saved items, four pinned. Seven rows sit fully above the fold and an eighth is partly visible, where the shipped page shows none of the library at all. The rail, the header and the composer are the entire chrome budget.", + "32 saved items, four pinned. Continue plus six saved rows sit fully above the fold and a seventh is partly visible, where the shipped page shows none of the library at all. The rail, the header, Continue and the composer are the entire chrome budget.", cost: "A row carries no description, so two similarly named forms are told apart by their code and set rather than by a summary line.", note: "interactive", }, @@ -163,6 +179,22 @@ const frames: ReadonlyArray<{ "The boundary of the whole feature: `canAccessFavouritesMode` is demo mode or authenticated. Drawn once so the gate is designed rather than inherited.", cost: "Nothing here hints at what is behind the gate beyond naming the four kinds.", }, + { + id: "no-continue", + number: "10", + name: "Without Continue", + summary: + "The same library with the resume strip removed, for comparison. One more row fits. The question is whether resuming what you were doing is worth a row of what you saved.", + cost: "Resuming costs a scan of the list instead of a tap, and after a set filter the item you were on may not be in view at all.", + }, + { + id: "type-chips", + number: "11", + name: "Type as a chip", + summary: + "The metadata line carries the shipped Recent card's pill instead of a coloured word. It scans faster down a column of mixed kinds, which is what the shipped card got right.", + cost: "The pill costs about 14px of a line that also holds the qualifier and the timestamp, so a long service name truncates sooner.", + }, ]; /* ═══════════════════════ the screen ═══════════════════════ */ @@ -176,6 +208,10 @@ function sortRows(rows: readonly FavouriteRow[], sort: SortMode) { } function FavouritesPhoneScreen({ state }: { state: FrameState }) { + // Continue is suppressed only in the comparison frame and in the states + // where there is nothing to resume. + const showContinue = !["no-continue", "empty", "signed-out", "no-matches"].includes(state); + const typeAs = state === "type-chips" ? "chip" : "word"; const [activeSet, setActiveSet] = useState(state === "set" ? "ward-round" : "all"); const [sort, setSort] = useState("recent"); const [sheet, setSheet] = useState( @@ -193,10 +229,26 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { const loadedRows = useMemo(() => (partial ? favouriteRows.slice(0, 26) : favouriteRows), [partial]); const queryMatched = useMemo(() => loadedRows.filter((row) => matchesQuery(row, query)), [loadedRows, query]); + // The item Continue is offering to resume. + const resumeRow = useMemo(() => [...loadedRows].sort((a, b) => a.recency - b.recency)[0], [loadedRows]); + + // What the search and the set actually matched, counted BEFORE Continue is + // lifted out of the list. Counting after it made an unfiltered library read + // "31 of 32 saved" while the All chip still said 32 — two answers to one + // question, which is a defect #164 removed from this page once already. + const matchedCount = useMemo( + () => (activeSet === "all" ? queryMatched : queryMatched.filter((row) => row.setId === activeSet)).length, + [queryMatched, activeSet], + ); + const visible = useMemo(() => { const inSet = activeSet === "all" ? queryMatched : queryMatched.filter((row) => row.setId === activeSet); - return sort === "recent" ? pinnedFirst(inSet) : sortRows(inSet, sort); - }, [queryMatched, activeSet, sort]); + // Drawn once. The shipped page shows the resumed item in Continue AND + // again in Recent AND again in the table; on a phone that is the same + // 72px row spent twice on the same thing, and it reads as a bug. + const withoutResume = showContinue ? inSet.filter((row) => row.id !== resumeRow.id) : inSet; + return sort === "recent" ? pinnedFirst(withoutResume) : sortRows(withoutResume, sort); + }, [queryMatched, activeSet, sort, showContinue, resumeRow]); // Rail counts reflect the search, so a chip never promises rows the query // has already excluded. @@ -225,16 +277,26 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { ) : ( <> - setSheet("page")} /> + setSheet("page")} /> setActiveSet(id as FavouriteSetId)} />
{partial ? : null} + {showContinue && visible.length > 0 ? ( + { + setActiveRow(row); + setSheet("item"); + }} + /> + ) : null} {visible.length === 0 ? ( ) : ( { setActiveRow(row); setSheet("item"); @@ -441,6 +503,11 @@ const decisions: ReadonlyArray<{ n: string; head: string; body: string }> = [ head: "The composer stays the only input", body: "Typing filters in place; the count becomes a matched-of-total pair and the rail counts re-weight. No header search field. The six existing favourites mockups each draw a second search bar, which the one-composer contract forbids.", }, + { + n: "6", + head: "Continue stays; Recent does not", + body: "The shipped Continue card measures 113px because the title, the metadata and a full-width button are three stacked things. Here the strip is the button, so it costs 72px — one row. The Recent card measures 277px to show three items the recency-sorted list already has as its first three rows.", + }, ]; export function FavouritesPhonePerfectedMockupsPage() { @@ -462,8 +529,8 @@ export function FavouritesPhonePerfectedMockupsPage() { page puts the first row of your saved list at y = 1141 — about 300px below the fold — behind a hint strip, a composer, a privacy notice, a results band, a Continue card and a Recent card, and then spends 228px on each item. Nothing of the library is on the first screen. This - direction spends 165px of chrome and 72px a row, which puts seven items - above the fold, and it is drawn across every state that actually occurs. + direction spends 165px of chrome and 72px a row, which puts Continue plus + six saved rows above the fold, and it is drawn across every state that actually occurs.

    diff --git a/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx b/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx index aa82673754..aeb8d83775 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx @@ -5,7 +5,9 @@ import { Check, CircleAlert, Copy, + ExternalLink, FolderInput, + History, ListFilter, MoreHorizontal, Pin, @@ -42,10 +44,14 @@ import { kindIdentity, setLabels, setOrder, type FavouriteRow, type FavouriteSet export function FavouriteListRow({ row, showPinGlyph, + typeAs = "word", onOpen, onOpenActions, }: { row: FavouriteRow; + /** `chip` is the pill the shipped Recent card uses. It scans faster and + * costs about 14px of the metadata line's width. */ + typeAs?: "word" | "chip"; /** Only when the list is ungrouped. Inside the Pinned group the glyph * repeats the label above it, and a title that wraps to two lines leaves it * stranded on a line of its own. */ @@ -83,12 +89,23 @@ export function FavouriteListRow({ {showPinGlyph && row.pinned ? ( ) : null} - - {identity.label} - - - · - + {typeAs === "chip" ? ( + + {identity.label} + + ) : ( + <> + + {identity.label} + + + · + + + )} {row.detail} · @@ -114,6 +131,60 @@ export function FavouriteListRow({ ); } +/** + * Continue — resume what you were last doing. + * + * This is the one derived card from the shipped page that survives, because it + * answers a different question from the list beneath it: "what was I in the + * middle of" rather than "what have I saved". It sits above the pinned group + * and does not scroll away, which is the whole point of a resume affordance. + * + * The shipped Continue card measures 113px because the title, the metadata and + * a full-width Continue button are three stacked things. Here the strip IS the + * button, so it costs 72px — one row — and keeps a 48px target. + * + * The Recent card deliberately did not survive: it measures 277px to show the + * three most recently opened items, and the list below it is already sorted by + * recency, so those same three are the first three rows. It buys nothing. + */ +export function ContinueStrip({ row, onOpen }: { row: FavouriteRow; onOpen: (row: FavouriteRow) => void }) { + const identity = kindIdentity[row.kind]; + return ( + + ); +} + function GroupLabel({ children }: { children: ReactNode }) { return (

    @@ -133,11 +204,13 @@ function GroupLabel({ children }: { children: ReactNode }) { export function FavouritesList({ rows, showPinnedGroup, + typeAs = "word", onOpen, onOpenActions, }: { rows: readonly FavouriteRow[]; showPinnedGroup: boolean; + typeAs?: "word" | "chip"; onOpen: (row: FavouriteRow) => void; onOpenActions: (row: FavouriteRow) => void; }) { @@ -155,6 +228,7 @@ export function FavouritesList({ key={row.id} row={row} showPinGlyph={!showPinnedGroup} + typeAs={typeAs} onOpen={onOpen} onOpenActions={onOpenActions} /> @@ -172,6 +246,7 @@ export function FavouritesList({ key={row.id} row={row} showPinGlyph={!showPinnedGroup} + typeAs={typeAs} onOpen={onOpen} onOpenActions={onOpenActions} /> From 3339a3c9f466ae17f33f5eba21c667380bfc2b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 23:53:52 +0000 Subject: [PATCH 03/12] Favourites mockup: draw the owner's Continue and Recent cards in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frame 10 restores both shipped cards as the owner asked for them, rather than only the compressed Continue strip: Continue with its own full-width action, Recent with View all, type pills and a per-row Open button. The library beneath them groups by set rather than by recency. That is what makes the arrangement coherent — a Recent card above a recency-sorted list repeats its own three rows, so keeping the card means the list should be the user's own filing. Continue, Recent and the library then answer three different questions: what was I mid-way through, what did I just touch, and what have I organised. View all switches the list to recency, so the control does something rather than sitting there. The cost is measured and drawn on the frame: Continue 152px (its title wraps at 390px where the shipped specimen's did not), Recent 271px, and one saved row above the fold against six with the compact strip. Both arrangements are kept because the choice is real and is the owner's. The shipped card tints its rule and kicker with --success. TOKENS.md scopes the clinical-state layer to source state and sanctioned urgency, and a resume affordance is neither, so the accent carries the same visual job inside the information layer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QtKvCY1d3cGd5EBvGNQouv --- mockups/README.md | 11 +- .../favourites-phone-perfected-page.tsx | 71 +++++++- .../favourites-rows.tsx | 171 ++++++++++++++++++ 3 files changed, 241 insertions(+), 12 deletions(-) diff --git a/mockups/README.md b/mockups/README.md index 61486e6951..2458176ec0 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -133,8 +133,15 @@ Continue, again in Recent and again in the table. Recent is dropped: the list be sorted by recency, so its three rows are the first three rows. Your sets is replaced by the rail, which is always visible for 53px instead of 255px. -Eleven phone frames cover library, one set selected, filtering, no matches, first run, item actions, -set management, partial load, signed out, and two comparisons — the library without Continue, and the +**Frame 10 draws the opposite choice**, added at the owner's request: both shipped cards rebuilt in +full — Continue with its own action, Recent with View all, type pills and per-row Open — and the +library grouped by set beneath them, so Continue, Recent and the library answer three different +questions rather than repeating one. Measured at 390px that lands **one** saved row above the fold +against six with the compact strip (Continue 152px with a title that wraps, Recent 271px). Both are +kept because the choice is real and belongs to the owner, not to the mockup. + +Twelve phone frames cover library, one set selected, filtering, no matches, first run, item actions, +set management, partial load, signed out, both cards restored, the library without Continue, and the type shown as the shipped Recent card's pill instead of a coloured word. One 1280px frame shows the desktop translation. diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx index 8ce7053176..d053cdd942 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx @@ -16,8 +16,10 @@ import { focusRing, } from "./favourites-phone-shell"; import { + ContinueCard, ContinueStrip, FavouritesList, + RecentCard, ItemActionsSheetBody, NoMatchesState, NothingSavedState, @@ -68,9 +70,12 @@ import { * 4. Pinning finally gets a control. `pinnedAt` has been in the schema and * the PATCH contract with no UI anywhere. * 5. The shared composer stays the only input. No search field in the header. - * 6. Continue survives from the shipped page, at 72px instead of 113px. The - * Recent card does not: it costs 277px to show three items the - * recency-sorted list already has as its first three rows. + * 6. Continue survives from the shipped page, at 72px instead of 113px, and + * the recency-sorted list makes a Recent card redundant. Frame 10 draws + * the opposite choice at the owner's request: both cards in full, with + * the library grouped by set so the three surfaces answer three different + * questions. Measured, that lands one saved row above the fold instead of + * six. The frames are the argument; the numbers are on each of them. * * WHAT IT REFUSES TO DRAW * @@ -92,7 +97,8 @@ type FrameState = | "partial" | "signed-out" | "no-continue" - | "type-chips"; + | "type-chips" + | "cards"; type SortMode = "recent" | "title" | "set"; @@ -180,8 +186,17 @@ const frames: ReadonlyArray<{ cost: "Nothing here hints at what is behind the gate beyond naming the four kinds.", }, { - id: "no-continue", + id: "cards", number: "10", + name: "Continue and Recent restored", + summary: + "Both shipped cards rebuilt in full — Continue with its own action, Recent with View all, type pills and per-row Open. The library beneath then groups by set rather than by recency, so the three parts answer three different questions instead of repeating one.", + cost: "Measured: Continue 152px, Recent 271px, and one saved row above the fold — against six with the compact strip. You land on the two cards and scroll to reach your library.", + note: "interactive", + }, + { + id: "no-continue", + number: "11", name: "Without Continue", summary: "The same library with the resume strip removed, for comparison. One more row fits. The question is whether resuming what you were doing is worth a row of what you saved.", @@ -189,7 +204,7 @@ const frames: ReadonlyArray<{ }, { id: "type-chips", - number: "11", + number: "12", name: "Type as a chip", summary: "The metadata line carries the shipped Recent card's pill instead of a coloured word. It scans faster down a column of mixed kinds, which is what the shipped card got right.", @@ -210,10 +225,17 @@ function sortRows(rows: readonly FavouriteRow[], sort: SortMode) { function FavouritesPhoneScreen({ state }: { state: FrameState }) { // Continue is suppressed only in the comparison frame and in the states // where there is nothing to resume. - const showContinue = !["no-continue", "empty", "signed-out", "no-matches"].includes(state); - const typeAs = state === "type-chips" ? "chip" : "word"; + const showContinue = !["no-continue", "empty", "signed-out", "no-matches", "cards"].includes(state); + // The shipped page's two cards, restored in full. The library beneath then + // groups by set: a recency-sorted list under a Recent card would repeat its + // three rows immediately. + const cardsMode = state === "cards"; + const typeAs = state === "type-chips" || cardsMode ? "chip" : "word"; const [activeSet, setActiveSet] = useState(state === "set" ? "ward-round" : "all"); - const [sort, setSort] = useState("recent"); + // Cards mode lands on the user's own filing, because a recency-sorted list + // under a Recent card repeats it. "View all" switches to recency, which is + // what makes that control do something. + const [sort, setSort] = useState(state === "cards" ? "set" : "recent"); const [sheet, setSheet] = useState( state === "item-sheet" ? "item" : state === "sets-sheet" ? "sets" : null, ); @@ -241,6 +263,15 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { [queryMatched, activeSet], ); + const recentRows = useMemo( + () => + [...loadedRows] + .sort((a, b) => a.recency - b.recency) + .filter((row) => row.id !== resumeRow.id) + .slice(0, 3), + [loadedRows, resumeRow], + ); + const visible = useMemo(() => { const inSet = activeSet === "all" ? queryMatched : queryMatched.filter((row) => row.setId === activeSet); // Drawn once. The shipped page shows the resumed item in Continue AND @@ -290,12 +321,32 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { }} /> ) : null} + {cardsMode ? ( +

    + { + setActiveRow(row); + setSheet("item"); + }} + /> + { + setActiveRow(row); + setSheet("item"); + }} + onViewAll={() => setSort("recent")} + /> +
    + ) : null} {visible.length === 0 ? ( ) : ( { setActiveRow(row); diff --git a/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx b/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx index aeb8d83775..5c6ede16df 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx @@ -185,6 +185,141 @@ export function ContinueStrip({ row, onOpen }: { row: FavouriteRow; onOpen: (row ); } +/** + * Continue, as the shipped page draws it: the title and its metadata, then a + * full-width action beneath them. 113px measured. + * + * `ContinueStrip` above is the 72px compression of the same idea. Both are + * kept because the choice between them is a real one — this version is a + * bigger, more obvious target and reads as an action; the strip reads as the + * first row of the list and costs a third as much. + * + * The shipped card tints its rule and kicker with `--success`. That token is + * the clinical-state layer, and TOKENS.md scopes it to source state and + * sanctioned urgency only — a resume affordance is neither. The accent is used + * here instead, which is the same visual job inside the information layer. + */ +export function ContinueCard({ row, onOpen }: { row: FavouriteRow; onOpen: (row: FavouriteRow) => void }) { + const identity = kindIdentity[row.kind]; + const Glyph = identity.icon; + return ( +
    +
    + +

    + + Continue + {" "} + {row.title} +

    +
    +

    + {setLabels[row.setId]} · last opened {row.lastOpened} +

    + +
    + ); +} + +/** + * Recent, as the shipped page draws it: a titled card, a View all escape, and + * three rows each carrying a type pill and its own Open button. 277px measured. + * + * It only earns that space if the list beneath it is NOT sorted by recency — + * otherwise its three rows are that list's first three rows and the card is a + * second copy. So a page that keeps this card should default the list to the + * user's own filing (grouped by set), which is what `FavouritesList`'s + * `groupBy="set"` does. Continue, Recent and the library then answer three + * different questions: what was I mid-way through, what did I just touch, and + * what have I organised. + */ +export function RecentCard({ + rows, + onOpen, + onViewAll, +}: { + rows: readonly FavouriteRow[]; + onOpen: (row: FavouriteRow) => void; + onViewAll: () => void; +}) { + return ( +
    +
    +

    + + Recent +

    + +
    +
      + {rows.map((row) => { + const identity = kindIdentity[row.kind]; + return ( +
    • + + + + {identity.label} + + + {row.title} + + + + {setLabels[row.setId]} · {row.lastOpened} + + + +
    • + ); + })} +
    +
    + ); +} + function GroupLabel({ children }: { children: ReactNode }) { return (

    @@ -204,12 +339,17 @@ function GroupLabel({ children }: { children: ReactNode }) { export function FavouritesList({ rows, showPinnedGroup, + groupBySet = false, typeAs = "word", onOpen, onOpenActions, }: { rows: readonly FavouriteRow[]; showPinnedGroup: boolean; + /** Group under set names instead. The shape a page keeping the Recent card + * needs, so the library is the user's own filing rather than a second + * recency list. */ + groupBySet?: boolean; typeAs?: "word" | "chip"; onOpen: (row: FavouriteRow) => void; onOpenActions: (row: FavouriteRow) => void; @@ -217,6 +357,37 @@ export function FavouritesList({ const pinned = showPinnedGroup ? rows.filter((row) => row.pinned) : []; const rest = showPinnedGroup ? rows.filter((row) => !row.pinned) : rows; + if (groupBySet) { + const bySet = setOrder + .filter((id) => id !== "all") + .map((id) => ({ id, label: setLabels[id], items: rows.filter((row) => row.setId === id) })) + .filter((group) => group.items.length > 0); + + return ( +

    + {bySet.map((group) => ( +
    + + {group.label} · {group.items.length} + +
      + {group.items.map((row) => ( + + ))} +
    +
    + ))} +
    + ); + } + return (
    {pinned.length > 0 ? ( From 7d50c9fad744337af4be9a410f78abb52bf3c0f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 00:07:06 +0000 Subject: [PATCH 04/12] Favourites mockup: make the cards arrangement the design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner chose the Continue + Recent arrangement over the compressed resume strip, so it stops being frame 10 and becomes the design: it leads the study, and it is drawn across every state rather than one screen. The strip is kept as frame 10, the record of the choice. Drawing it across the states needed a rule, because the cards cost the fold. This is it: Continue and Recent are the landing surface, and nothing else. Tap a set or type in the composer and they hand the screen back to the list. Narrowing means you are hunting for something specific, and a resume affordance is not what you asked for. Measured: one saved row above the fold on arrival, seven the moment you narrow — so the cards are paid for on the only screen where arriving, rather than searching, is the task. That rule also fixed a defect it exposed. In the partial-load state the failure notice plus both full cards left ZERO saved rows above the fold — the one screen that most needs to show what survived was showing none of it. A degraded load now falls back to the 72px strip, which puts five rows back and keeps resume reachable. The library groups by the user's own sets rather than by recency, since a recency-sorted list under a Recent card is a second copy of it. Type pills are now the default on rows; the coloured word is kept as frame 11. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QtKvCY1d3cGd5EBvGNQouv --- mockups/README.md | 67 ++++--- .../favourites-phone-perfected-page.tsx | 187 ++++++++++-------- 2 files changed, 137 insertions(+), 117 deletions(-) diff --git a/mockups/README.md b/mockups/README.md index 2458176ec0..7ef0d3a981 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -97,7 +97,7 @@ no production file uses a bare `grid-cols-6` (only `xl:grid-cols-6`). The 26-let `gridTemplateColumns` inline rather than depending on class generation — a bare `grid-cols-6` silently collapses them to one column. -## Favourites, phone-first (2026-08-26) +## Favourites, phone-first (2026-08-26, arrangement chosen 2026-08-27) Runnable study at [`/mockups/favourites-phone-perfected`](../src/app/mockups/favourites-phone-perfected/page.tsx). One perfected direction rather than a set of alternatives — the six earlier favourites studies @@ -108,42 +108,43 @@ One perfected direction rather than a set of alternatives — the six earlier fa `getBoundingClientRect()` on the live `/favourites` route: the first row of the saved list begins at **y = 1141**, roughly 300px below the fold, behind a hint strip, an in-flow composer, a privacy notice, a results band, a Continue card and a Recent card. Each item card is **228px**. Nothing of -the library itself is on the first screen. This direction spends **165px** of app chrome and **72px** -a row, which puts **seven rows fully above the fold and an eighth partly**. - -| Decision | Trade-off | -| ------------------------------------------ | -------------------------------------------------------------------------- | -| One header, not six bands | Sort, sets and clear-all cost a tap behind the ellipsis sheet | -| Sets as a scrolling chip rail | Later sets sit off-screen until the rail is scrolled | -| One-line rows | No description, so near-identical forms are told apart by code and set | -| Pinned rows lead, with a real toggle | One 28px group label, which disappears when nothing is pinned | -| The shared composer stays the only input | The input is at the far end of the phone from the count it changes | -| Continue survives at 72px; Recent does not | Continue costs exactly one saved row (six above the fold instead of seven) | +the library itself is on the first screen. The three derived cards above the list measure Continue +113px, Recent 277px and Your sets 255px — 645px of that 1141px. + +**The arrangement, chosen by the owner 2026-08-27.** Both derived cards are kept and drawn in full — +Continue with its own action, Recent with View all, type pills and per-row Open — rather than the +72px compressed resume strip the first pass proposed. What pays for them is a single rule: + +> Continue and Recent are the **landing** surface, and nothing else. Tap a set or type in the +> composer and they hand the screen back to the list. + +Narrowing means the user is hunting for something specific, and a resume affordance is not what they +asked for. Measured: **one** saved row above the fold on arrival, **seven** the moment you narrow. A +degraded load also falls back to the strip — with the failure notice plus both full cards, **zero** +saved rows fitted, which is the wrong screen to show nothing on. + +The library groups by the user's own sets rather than by recency, because a recency-sorted list under +a Recent card is a second copy of that card. Continue, Recent and the library then answer three +different questions: what was I mid-way through, what did I just touch, what have I filed. `View all` +switches the list to recency, which is what makes that control do something. + +| Decision | Trade-off | +| ----------------------------------------------- | ---------------------------------------------------------------------- | +| Continue and Recent as the landing surface only | One saved row above the fold on arrival | +| Library groups by set, not recency | Finding the newest thing means Recent or View all | +| One header, not six bands | Sort, sets and clear-all cost a tap behind the ellipsis sheet | +| One-line rows with a type pill | No description, so near-identical forms are told apart by code and set | +| Pinned rows lead, with a real toggle | One 28px group label, which disappears when nothing is pinned | +| The shared composer stays the only input | The input is at the far end of the phone from the count it changes | A **weighted segment track** (the `DocumentSectionTrack` shape the in-page navigation template prescribes) was tried first and dropped: eight sets across 390px leaves each segment about 48px, under the width a set name needs, so the track degrades to unlabelled slivers. -**What survived from the shipped page.** Measured at 390px, the three derived cards above the list are -Continue 113px, Recent 277px and Your sets 255px — 645px of the 1141px. Continue is kept because it -answers a different question from the list ("what was I in the middle of", not "what have I saved"), -rebuilt so the strip _is_ the button rather than stacking a button under a title: 72px, exactly one -row, and it does not scroll away. The resumed item is then drawn once — the shipped page shows it in -Continue, again in Recent and again in the table. Recent is dropped: the list below it is already -sorted by recency, so its three rows are the first three rows. Your sets is replaced by the rail, -which is always visible for 53px instead of 255px. - -**Frame 10 draws the opposite choice**, added at the owner's request: both shipped cards rebuilt in -full — Continue with its own action, Recent with View all, type pills and per-row Open — and the -library grouped by set beneath them, so Continue, Recent and the library answer three different -questions rather than repeating one. Measured at 390px that lands **one** saved row above the fold -against six with the compact strip (Continue 152px with a title that wraps, Recent 271px). Both are -kept because the choice is real and belongs to the owner, not to the mockup. - -Twelve phone frames cover library, one set selected, filtering, no matches, first run, item actions, -set management, partial load, signed out, both cards restored, the library without Continue, and the -type shown as the shipped Recent card's pill instead of a coloured word. One 1280px frame shows the -desktop translation. +Twelve phone frames: landing, one set selected, filtering, no matches, first run, item actions, set +management, partial load, signed out, and two kept alternatives — the compact resume strip (the +rejected first pass, kept as the record of the choice) and the type drawn as a coloured word rather +than a pill. One 1280px frame shows the desktop translation. **Content honesty.** Only `service | form | differential | therapy` are drawn, because `favouriteContentTypeSchema` permits nothing else. The six earlier favourites mockups draw saved @@ -151,6 +152,8 @@ medications, documents, quotes and searches, none of which has a content type an be persisted. `tests/favourites-phone-perfected-mockups.test.ts` pins that, the controlled set vocabulary, and the 48px tap knob. Differentials and therapies borrow `--tone-purple` / `--tone-indigo` because the identity group has no `--type-differential` or `--type-therapy`; promotion would add them. +The shipped Continue card tints its rule with `--success`; TOKENS.md scopes the clinical-state layer +to source state and sanctioned urgency, so the accent carries that job here instead. Shared chrome is suppressed because every frame draws its own top bar, page header and composer. diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx index d053cdd942..8aef1c738f 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx @@ -41,7 +41,7 @@ import { } from "./fixtures"; /** - * Design scratch: Favourites on a phone, one perfected direction. + * Design scratch: Favourites on a phone, the chosen arrangement. * * WHAT THIS ARGUES * @@ -52,30 +52,37 @@ import { * strip, an in-flow composer, a privacy notice, a results band, a Continue * card and a Recent card. None of the library itself is on the first screen. * - * This design puts 165px of app chrome above the list and 72px rows under it. - * Measured: the Continue strip plus six saved rows sit fully above the fold - * and a seventh is partly visible; with Continue removed it is seven rows. - * The strip costs exactly one row. + * THE ARRANGEMENT * - * The argument here is one sentence: for a surface whose entire content is - * things you already chose, THE LIST IS THE PAGE, and a band earns its - * vertical space only by beating a row of favourites for it. + * The owner chose to keep both derived cards rather than the compressed + * resume strip, so this is drawn as the design and the strip is kept as + * frame 10, the record of the choice. What makes the cards affordable is a + * single rule: * - * FIVE DECISIONS + * Continue and Recent are the LANDING surface, and nothing else. Tap a set + * or type in the composer and they give the screen back to the list. * - * 1. One header, not six bands. Title, live count, one ellipsis sheet. - * 2. Sets are the navigation, as a scrolling chip rail. The weighted segment - * track was tried and dropped - see `SetRail` for why. - * 3. A row costs one line: 72px against the shipped card's measured 228px. - * 4. Pinning finally gets a control. `pinnedAt` has been in the schema and - * the PATCH contract with no UI anywhere. - * 5. The shared composer stays the only input. No search field in the header. - * 6. Continue survives from the shipped page, at 72px instead of 113px, and - * the recency-sorted list makes a Recent card redundant. Frame 10 draws - * the opposite choice at the owner's request: both cards in full, with - * the library grouped by set so the three surfaces answer three different - * questions. Measured, that lands one saved row above the fold instead of - * six. The frames are the argument; the numbers are on each of them. + * Narrowing means you are hunting for something specific, and a resume + * affordance is not what you asked for. So the cards cost the fold only on + * the screen where arriving, not searching, is what you are doing: one saved + * row on the landing view, seven the moment you narrow. A degraded load also + * falls back to the 72px strip, because the notice plus two full cards left + * zero rows visible on the one screen that most needs to show what survived. + * + * The library groups by the user's own sets rather than by recency, because a + * recency-sorted list under a Recent card is a second copy of that card. So + * the three surfaces answer three different questions: what was I mid-way + * through, what did I just touch, and what have I filed. + * + * THE REST OF IT + * + * - One header, not six bands: title, live count, one ellipsis sheet. + * - Sets are the navigation, as a scrolling chip rail. The weighted segment + * track was tried and dropped - see `SetRail` for why. + * - A row costs one line: 72px against the shipped card's measured 228px. + * - Pinning finally gets a control. `pinnedAt` has been in the schema and + * the PATCH contract with no UI anywhere. + * - The shared composer stays the only input. No search field in the header. * * WHAT IT REFUSES TO DRAW * @@ -96,9 +103,8 @@ type FrameState = | "sets-sheet" | "partial" | "signed-out" - | "no-continue" - | "type-chips" - | "cards"; + | "compact-strip" + | "type-word"; type SortMode = "recent" | "title" | "set"; @@ -113,10 +119,10 @@ const frames: ReadonlyArray<{ { id: "library", number: "01", - name: "The library", + name: "Landing", summary: - "32 saved items, four pinned. Continue plus six saved rows sit fully above the fold and a seventh is partly visible, where the shipped page shows none of the library at all. The rail, the header, Continue and the composer are the entire chrome budget.", - cost: "A row carries no description, so two similarly named forms are told apart by their code and set rather than by a summary line.", + "Continue and Recent as the shipped page draws them, then the library grouped by your own sets. The three answer three different questions: what was I mid-way through, what did I just touch, and what have I filed.", + cost: "Measured: Continue 152px, Recent 271px, one saved row above the fold. That is the price of arriving on the two cards, and frame 10 draws the alternative.", note: "interactive", }, { @@ -124,8 +130,8 @@ const frames: ReadonlyArray<{ number: "02", name: "One set selected", summary: - "Ward round, 7 items. The rail is the filter; the header count follows it. Pinned items inside the set still lead.", - cost: "Later sets sit off-screen until you scroll the rail. That is the price of keeping every set name legible.", + "Tap a set and the cards hand the screen back. Narrowing means you are hunting for something specific, and a resume affordance is not what you asked for — so seven rows of Ward round fill the space the cards had.", + cost: "Continue is two taps away again once you have narrowed: clear the set, then tap it.", note: "interactive", }, { @@ -133,7 +139,7 @@ const frames: ReadonlyArray<{ number: "03", name: "Filtering as you type", summary: - "Typing in the shared composer filters in place. The header becomes a matched-of-total pair and the rail counts re-weight to the match, so no chip promises rows the search has already excluded.", + "Typing in the shared composer does the same thing a set chip does — the cards give way and matches fill the screen. The header becomes a matched-of-total pair and the rail counts re-weight, so no chip promises rows the search has excluded.", cost: "The composer is at the far end of the phone from the count it changes.", }, { @@ -149,7 +155,7 @@ const frames: ReadonlyArray<{ number: "05", name: "Nothing saved yet", summary: - "First run names the four things that can actually be saved. Somebody with an empty library cannot infer them, and no other surface tells them.", + "First run names the four things that can actually be saved. Somebody with an empty library cannot infer them, and no other surface tells them. No cards, because there is nothing to resume.", cost: "It spends the whole first screen on explanation, which is only ever seen once.", }, { @@ -157,7 +163,7 @@ const frames: ReadonlyArray<{ number: "06", name: "Item actions", summary: - "Everything the card carried inline, plus the pin toggle the API has always supported and no screen has ever exposed. Four full-width 48px targets.", + "Everything the shipped card carried inline, plus the pin toggle the API has always supported and no screen has ever exposed. Four full-width 48px targets.", cost: "Removing a favourite is now two taps rather than one. On a destructive action that is a gain.", note: "interactive", }, @@ -175,7 +181,7 @@ const frames: ReadonlyArray<{ name: "Partial load", summary: "Some favourites failed to fetch. The header reports what actually loaded, the notice says how many did not, and Retry is present. No fabricated zero.", - cost: "A warning band above the list is the one band that buys its space back, and only in this state.", + cost: "Resume degrades to the 72px strip here — with the notice and the two full cards, zero saved rows fitted above the fold, which is the wrong screen to show nothing on.", }, { id: "signed-out", @@ -186,29 +192,21 @@ const frames: ReadonlyArray<{ cost: "Nothing here hints at what is behind the gate beyond naming the four kinds.", }, { - id: "cards", + id: "compact-strip", number: "10", - name: "Continue and Recent restored", + name: "Alternative — compact resume strip", summary: - "Both shipped cards rebuilt in full — Continue with its own action, Recent with View all, type pills and per-row Open. The library beneath then groups by set rather than by recency, so the three parts answer three different questions instead of repeating one.", - cost: "Measured: Continue 152px, Recent 271px, and one saved row above the fold — against six with the compact strip. You land on the two cards and scroll to reach your library.", + "The rejected alternative, kept as the record of a real choice. Continue compressed to a 72px strip where the strip is the button, no Recent card, and the library sorted by recency beneath it.", + cost: "Six saved rows above the fold instead of one — but resuming and re-opening this morning's work both become a scan rather than a tap.", note: "interactive", }, { - id: "no-continue", + id: "type-word", number: "11", - name: "Without Continue", - summary: - "The same library with the resume strip removed, for comparison. One more row fits. The question is whether resuming what you were doing is worth a row of what you saved.", - cost: "Resuming costs a scan of the list instead of a tap, and after a set filter the item you were on may not be in view at all.", - }, - { - id: "type-chips", - number: "12", - name: "Type as a chip", + name: "Alternative — type as a word", summary: - "The metadata line carries the shipped Recent card's pill instead of a coloured word. It scans faster down a column of mixed kinds, which is what the shipped card got right.", - cost: "The pill costs about 14px of a line that also holds the qualifier and the timestamp, so a long service name truncates sooner.", + "The chosen rows carry the shipped Recent card's type pill. This draws the alternative: the type as a coloured word on the metadata line, which buys back about 14px of a line that also holds the qualifier and the timestamp.", + cost: "Down a column of mixed kinds the word is markedly harder to scan than the pill, which is what the shipped Recent card got right.", }, ]; @@ -223,19 +221,17 @@ function sortRows(rows: readonly FavouriteRow[], sort: SortMode) { } function FavouritesPhoneScreen({ state }: { state: FrameState }) { - // Continue is suppressed only in the comparison frame and in the states - // where there is nothing to resume. - const showContinue = !["no-continue", "empty", "signed-out", "no-matches", "cards"].includes(state); - // The shipped page's two cards, restored in full. The library beneath then - // groups by set: a recency-sorted list under a Recent card would repeat its - // three rows immediately. - const cardsMode = state === "cards"; - const typeAs = state === "type-chips" || cardsMode ? "chip" : "word"; + // The two alternatives the study keeps for comparison. Everything else is + // the chosen arrangement. + const compactAlternative = state === "compact-strip"; + const typeAs = state === "type-word" ? "word" : "chip"; + const [activeSet, setActiveSet] = useState(state === "set" ? "ward-round" : "all"); - // Cards mode lands on the user's own filing, because a recency-sorted list - // under a Recent card repeats it. "View all" switches to recency, which is - // what makes that control do something. - const [sort, setSort] = useState(state === "cards" ? "set" : "recent"); + // The landing view is the user's own filing. A recency-sorted list under a + // Recent card repeats that card's three rows, so keeping the card means the + // library below it has to be something else. "View all" switches to + // recency, which is what makes that control do something. + const [sort, setSort] = useState(compactAlternative ? "recent" : "set"); const [sheet, setSheet] = useState( state === "item-sheet" ? "item" : state === "sets-sheet" ? "sets" : null, ); @@ -272,14 +268,35 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { [loadedRows, resumeRow], ); + /** + * THE RULE THAT PAYS FOR THE CARDS. + * + * Continue and Recent are the landing surface for the whole library, and + * nothing else. The moment you narrow — tap a set, or type in the composer — + * they give the screen back to the list, because at that point you are + * hunting for something specific and a resume affordance is not what you + * asked for. So the cards cost the fold only on the screen where arriving, + * not searching, is what you are doing. + */ + const narrowed = activeSet !== "all" || query.trim() !== ""; + const hasLibrary = !empty && !signedOut && loadedRows.length > 0; + // A degraded load falls back to the cheap resume form. Measured, the failure + // notice plus the two full cards left ZERO saved rows above the fold: the + // one state where the user most needs to see what survived is the one where + // the cards leave no room for it. The 72px strip keeps resume reachable and + // gives the rows back. + const showCards = hasLibrary && !narrowed && !compactAlternative && !partial; + const showStrip = hasLibrary && !narrowed && (compactAlternative || partial); + const visible = useMemo(() => { const inSet = activeSet === "all" ? queryMatched : queryMatched.filter((row) => row.setId === activeSet); // Drawn once. The shipped page shows the resumed item in Continue AND // again in Recent AND again in the table; on a phone that is the same // 72px row spent twice on the same thing, and it reads as a bug. - const withoutResume = showContinue ? inSet.filter((row) => row.id !== resumeRow.id) : inSet; + const liftedOut = showCards || showStrip; + const withoutResume = liftedOut ? inSet.filter((row) => row.id !== resumeRow.id) : inSet; return sort === "recent" ? pinnedFirst(withoutResume) : sortRows(withoutResume, sort); - }, [queryMatched, activeSet, sort, showContinue, resumeRow]); + }, [queryMatched, activeSet, sort, showCards, showStrip, resumeRow]); // Rail counts reflect the search, so a chip never promises rows the query // has already excluded. @@ -312,7 +329,7 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { setActiveSet(id as FavouriteSetId)} />
    {partial ? : null} - {showContinue && visible.length > 0 ? ( + {showStrip ? ( { @@ -321,7 +338,7 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { }} /> ) : null} - {cardsMode ? ( + {showCards ? (
    { setActiveRow(row); @@ -531,33 +548,33 @@ function DesktopReference() { const decisions: ReadonlyArray<{ n: string; head: string; body: string }> = [ { n: "1", - head: "One header, not six bands", - body: "Title, live count, one ellipsis sheet. Sort, set management and clear-all move behind it. Everything above the list — app header, page header and set rail — measures 165px.", + head: "Continue and Recent are the landing surface", + body: "Both shipped cards, drawn in full. They show on arrival and nowhere else: tap a set or type in the composer and they hand the screen back to the list. That rule is what pays for them — one saved row above the fold on arrival, seven the moment you narrow.", }, { n: "2", - head: "Sets become the navigation", - body: "A scrolling chip rail carrying every set name and its count. A weighted segment track was tried first and dropped: eight sets across 390px leaves each about 48px, under the width a set name needs.", + head: "The library groups by your sets", + body: "Not by recency. A recency-sorted list under a Recent card is a second copy of that card, so keeping the card means the library below it has to be your own filing. View all switches to recency when that is what you want.", }, { n: "3", - head: "A row costs one line", - body: "Glyph, title, one metadata line, one trailing control. 72px against the shipped card's measured 228px, and both targets clear 44px.", + head: "One header, not six bands", + body: "Title, live count, one ellipsis sheet. Sort, set management and clear-all move behind it. Everything above the list — app header, page header and set rail — measures 165px.", }, { n: "4", - head: "Pinning finally gets a control", - body: "`pinnedAt` has been in the schema and the PATCH contract since August with no UI anywhere. Pinned rows lead every view under one label that disappears when nothing is pinned.", + head: "A row costs one line", + body: "Type pill, title, one metadata line, one trailing control. 72px against the shipped card's measured 228px, and both targets clear 44px.", }, { n: "5", - head: "The composer stays the only input", - body: "Typing filters in place; the count becomes a matched-of-total pair and the rail counts re-weight. No header search field. The six existing favourites mockups each draw a second search bar, which the one-composer contract forbids.", + head: "Pinning finally gets a control", + body: "`pinnedAt` has been in the schema and the PATCH contract since August with no UI anywhere. Pinned rows lead every view under one label that disappears when nothing is pinned.", }, { n: "6", - head: "Continue stays; Recent does not", - body: "The shipped Continue card measures 113px because the title, the metadata and a full-width button are three stacked things. Here the strip is the button, so it costs 72px — one row. The Recent card measures 277px to show three items the recency-sorted list already has as its first three rows.", + head: "The composer stays the only input", + body: "Typing filters in place; the count becomes a matched-of-total pair and the rail counts re-weight. No header search field. The six existing favourites mockups each draw a second search bar, which the one-composer contract forbids.", }, ]; @@ -577,11 +594,11 @@ export function FavouritesPhonePerfectedMockupsPage() {

    Favourites is where you go to get back to something you already chose. Measured at 390 × 844, the shipped - page puts the first row of your saved list at y = 1141 — about 300px below the fold — - behind a hint strip, a composer, a privacy notice, a results band, a Continue card and a Recent card, and - then spends 228px on each item. Nothing of the library is on the first screen. This - direction spends 165px of chrome and 72px a row, which puts Continue plus - six saved rows above the fold, and it is drawn across every state that actually occurs. + page puts the first row of your saved list at y = 1141 — about 300px below the fold — and + then spends 228px on each item. Nothing of the library is on the first screen. This keeps + the Continue and Recent cards, and pays for them with one rule: they are the landing surface only. Tap a set + or type in the composer and they hand the screen back to the list —one saved row above the + fold on arrival, seven the moment you narrow.

      From 6c7d5616eb9402054c3ba3f3f28ba3eaa5c61e92 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:29:48 +0800 Subject: [PATCH 05/12] fix(mockups): address Codex review on favourites phone-perfected study Co-authored-by: Cursor --- data/repo-awareness-snapshot.json | 4 +- .../favourites-phone-perfected-page.tsx | 14 +++- .../favourites-phone-shell.tsx | 2 +- .../favourites-rows.tsx | 71 +++++++++++++++++-- .../fixtures.ts | 11 +++ ...favourites-phone-perfected-mockups.test.ts | 44 +++++++++++- 6 files changed, 135 insertions(+), 11 deletions(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 750f0685f8..200c7d2e82 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "e880fa2acebb2296b10caed31181714b50f79c97", - "committed_at": "2026-08-26T23:14:35Z" + "sha": "d2c61c8cbddfb096e7b86e4dae1983dd1266a356", + "committed_at": "2026-08-27T09:12:09+08:00" }, "routes": { "modes": [ diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx index 8aef1c738f..9cb6a37d0b 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx @@ -16,6 +16,7 @@ import { focusRing, } from "./favourites-phone-shell"; import { + ClearAllSheetBody, ContinueCard, ContinueStrip, FavouritesList, @@ -232,7 +233,7 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { // library below it has to be something else. "View all" switches to // recency, which is what makes that control do something. const [sort, setSort] = useState(compactAlternative ? "recent" : "set"); - const [sheet, setSheet] = useState( + const [sheet, setSheet] = useState( state === "item-sheet" ? "item" : state === "sets-sheet" ? "sets" : null, ); const [activeRow, setActiveRow] = useState(favouriteRows[1]); @@ -406,6 +407,17 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { setSheet(null); }} onOpenSets={() => setSheet("sets")} + onRequestClearAll={() => setSheet("clear-all")} + /> + + ) : null} + + {sheet === "clear-all" ? ( + setSheet("page")}> + setSheet("page")} + onConfirm={() => setSheet(null)} /> ) : null} diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx index cb6791d416..70facefd18 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-shell.tsx @@ -191,7 +191,7 @@ export function SetRail({ aria-pressed={active} onClick={() => onSelect(set.id)} className={cn( - "inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-pill border px-3 text-2xs font-bold transition", + "inline-flex min-h-12 shrink-0 items-center gap-1.5 rounded-pill border px-3 text-2xs font-bold transition", focusRing, active ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" diff --git a/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx b/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx index 5c6ede16df..31761088d0 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-rows.tsx @@ -270,7 +270,7 @@ export function RecentCard({ type="button" onClick={onViewAll} className={cn( - "-mr-1 inline-flex min-h-9 items-center rounded-lg px-2 text-2xs font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]", + "-mr-1 inline-flex min-h-12 items-center rounded-lg px-2 text-2xs font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]", focusRing, )} > @@ -358,13 +358,32 @@ export function FavouritesList({ const rest = showPinnedGroup ? rows.filter((row) => !row.pinned) : rows; if (groupBySet) { + const pinnedInView = rows.filter((row) => row.pinned); + const restInView = rows.filter((row) => !row.pinned); const bySet = setOrder .filter((id) => id !== "all") - .map((id) => ({ id, label: setLabels[id], items: rows.filter((row) => row.setId === id) })) + .map((id) => ({ id, label: setLabels[id], items: restInView.filter((row) => row.setId === id) })) .filter((group) => group.items.length > 0); return (
      + {pinnedInView.length > 0 ? ( + <> + Pinned +
        + {pinnedInView.map((row) => ( + + ))} +
      + + ) : null} {bySet.map((group) => (
      @@ -534,7 +553,7 @@ export function PartialLoadNotice({ failed }: { failed: number }) { type="button" onClick={() => undefined} className={cn( - "inline-flex min-h-9 shrink-0 items-center gap-1.5 self-start rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--surface)] px-2.5 text-2xs font-bold text-[color:var(--warning-text)]", + "inline-flex min-h-12 shrink-0 items-center gap-1.5 self-start rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--surface)] px-2.5 text-2xs font-bold text-[color:var(--warning-text)]", focusRing, )} > @@ -678,16 +697,60 @@ export function SetsSheetBody({ counts, onClose }: { counts: Record void; + onConfirm: () => void; +}) { + return ( +
      +

      + This removes all {total} saved favourites from your account on every device. It cannot be undone. +

      +
      + + +
      +
      + ); +} + /** The page-level sheet: sort, sets, and the destructive action, which belongs * behind a sheet rather than on the header where it can be hit by accident. */ export function PageActionsSheetBody({ sort, onSelectSort, onOpenSets, + onRequestClearAll, }: { sort: "recent" | "title" | "set"; onSelectSort: (value: "recent" | "title" | "set") => void; onOpenSets: () => void; + onRequestClearAll: () => void; }) { const options: ReadonlyArray<{ value: "recent" | "title" | "set"; label: string; hint: string }> = [ { value: "recent", label: "Recently opened", hint: "Default" }, @@ -717,7 +780,7 @@ export function PageActionsSheetBody({ Library

      - +
      ); } diff --git a/src/components/favourites-phone-perfected-mockups/fixtures.ts b/src/components/favourites-phone-perfected-mockups/fixtures.ts index aabf8ea723..1c4f9f9462 100644 --- a/src/components/favourites-phone-perfected-mockups/fixtures.ts +++ b/src/components/favourites-phone-perfected-mockups/fixtures.ts @@ -422,6 +422,17 @@ export function countsBySet(rows: readonly FavouriteRow[]): Record row.pinned).map((row) => row.id); + const rest = rows.filter((row) => !row.pinned); + const bySet = setOrder + .filter((id) => id !== "all") + .flatMap((id) => rest.filter((row) => row.setId === id).map((row) => row.id)); + return [...pinned, ...bySet]; +} + export function pinnedFirst(rows: readonly FavouriteRow[]): FavouriteRow[] { return [...rows].sort((a, b) => { if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1; diff --git a/tests/favourites-phone-perfected-mockups.test.ts b/tests/favourites-phone-perfected-mockups.test.ts index a0f560ead2..1c72762ba4 100644 --- a/tests/favourites-phone-perfected-mockups.test.ts +++ b/tests/favourites-phone-perfected-mockups.test.ts @@ -5,7 +5,12 @@ import { describe, expect, it } from "vitest"; import { favouriteContentTypeSchema, favouriteSetNames } from "@/lib/favourites-contract"; -import { favouriteRows, kindIdentity, setLabels } from "@/components/favourites-phone-perfected-mockups/fixtures"; +import { + favouriteRows, + kindIdentity, + landingLibrarySectionOrder, + setLabels, +} from "@/components/favourites-phone-perfected-mockups/fixtures"; /** * The phone Favourites mockup is design scratch and deliberately untested for @@ -91,9 +96,42 @@ describe("favourites phone-perfected mockup", () => { it("keeps every phone tap target at the production 48px knob", () => { // Mockups are gate-exempt and may use min-h-11, but this one is authored - // to be promotable, so it never drops below the production knob. + // to be promotable, so interactive rows and sheets never drop below 48px. for (const file of ["favourites-rows.tsx", "favourites-phone-perfected-page.tsx"]) { - expect(source(file), `${file} must not use the 44px mockup target`).not.toMatch(/\bmin-h-11\b/); + const text = source(file); + expect(text, `${file} must not use the 44px mockup target`).not.toMatch(/\bmin-h-11\b/); + expect(text, `${file} must not use a 36px tap target`).not.toMatch(/\bmin-h-9\b/); } + + const shell = source("favourites-phone-shell.tsx"); + const setRailBlock = shell.split("export function SetRail")[1]?.split("export function PhoneComposer")[0] ?? ""; + expect(setRailBlock, "set chips must use the 48px production knob").toMatch(/\bmin-h-12\b/); + expect(setRailBlock, "set chips must not use a 36px tap target").not.toMatch(/\bmin-h-9\b/); + }); + + it("keeps pinned rows ahead of grouped sets on the landing view", () => { + const resumeRow = [...favouriteRows].sort((a, b) => a.recency - b.recency)[0]; + const landingRows = favouriteRows.filter((row) => row.id !== resumeRow.id); + const order = landingLibrarySectionOrder(landingRows); + const firstPinnedIndex = order.findIndex((id) => favouriteRows.find((row) => row.id === id)?.pinned); + const firstClinicalReviewIndex = order.findIndex( + (id) => + favouriteRows.find((row) => row.id === id)?.setId === "clinical-review" && + !favouriteRows.find((row) => row.id === id)?.pinned, + ); + + expect(firstPinnedIndex).toBeGreaterThanOrEqual(0); + expect(firstClinicalReviewIndex).toBeGreaterThan(firstPinnedIndex); + }); + + it("routes clear-all to its own confirmation sheet, not set management", () => { + const pageSource = source("favourites-phone-perfected-page.tsx"); + expect(pageSource).toContain('onRequestClearAll={() => setSheet("clear-all")}'); + expect(pageSource).toContain('sheet === "clear-all"'); + expect(pageSource).not.toMatch(/Remove all favourites.*onOpenSets/s); + + const rowsSource = source("favourites-rows.tsx"); + expect(rowsSource).toContain("onRequestClearAll"); + expect(rowsSource).toContain("ClearAllSheetBody"); }); }); From f19eedb8b1bfec96cba467c915100e334cf60eb6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:39:51 +0800 Subject: [PATCH 06/12] refactor(db-ui): add metadata validation constraints, remove dead component contracts, and close CLS baselines --- data/outstanding-issues-snapshot.json | 60 +++++- data/repo-awareness-snapshot.json | 9 +- .../accessibility-acceptance.md | 18 ++ docs/design-system/GATES.md | 23 +++ docs/operations-runbook.md | 191 ++++++++++++++++++ .../24d74362-023b-41a9-a287-76d13e580247.json | 11 + .../2ea5ba7b-fc5b-4765-bc01-7691f64f8afd.json | 11 + .../2f98413d-ebc4-4d3e-913d-b1bc95545c89.json | 11 + .../72e73379-be06-413a-b137-3e1f06e9a6b9.json | 11 + .../8024644a-f093-42f4-97c0-eabfaa981267.json | 11 + .../ad794482-fb8a-45e3-921e-c8f210bc1b5e.json | 11 + .../b762756a-db30-4ab5-9e0b-80050de94d24.json | 11 + .../bd64c286-20bd-4a41-9e18-1359fd5a772a.json | 11 + .../d24fe2f4-0a3d-4fc7-8181-f897f0cba415.json | 11 + docs/testing.md | 14 ++ .../clinical-dashboard/evidence-panels.tsx | 114 +---------- .../therapy-compass/therapy-card.tsx | 46 +---- ...cuments_metadata_structural_constraint.sql | 30 +++ supabase/schema.sql | 5 +- tests/adopt-visual-baselines.test.ts | 3 + tests/rendered-text-formatting.test.ts | 5 +- 21 files changed, 451 insertions(+), 166 deletions(-) create mode 100644 docs/operations-runbook.md create mode 100644 docs/outstanding-issues-inbox/24d74362-023b-41a9-a287-76d13e580247.json create mode 100644 docs/outstanding-issues-inbox/2ea5ba7b-fc5b-4765-bc01-7691f64f8afd.json create mode 100644 docs/outstanding-issues-inbox/2f98413d-ebc4-4d3e-913d-b1bc95545c89.json create mode 100644 docs/outstanding-issues-inbox/72e73379-be06-413a-b137-3e1f06e9a6b9.json create mode 100644 docs/outstanding-issues-inbox/8024644a-f093-42f4-97c0-eabfaa981267.json create mode 100644 docs/outstanding-issues-inbox/ad794482-fb8a-45e3-921e-c8f210bc1b5e.json create mode 100644 docs/outstanding-issues-inbox/b762756a-db30-4ab5-9e0b-80050de94d24.json create mode 100644 docs/outstanding-issues-inbox/bd64c286-20bd-4a41-9e18-1359fd5a772a.json create mode 100644 docs/outstanding-issues-inbox/d24fe2f4-0a3d-4fc7-8181-f897f0cba415.json create mode 100644 supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql diff --git a/data/outstanding-issues-snapshot.json b/data/outstanding-issues-snapshot.json index 7c19dba753..92d436dea1 100644 --- a/data/outstanding-issues-snapshot.json +++ b/data/outstanding-issues-snapshot.json @@ -1,8 +1,8 @@ { "version": "outstanding-issues-snapshot-v1", "ledger_revision": { - "sha": "6085a0a59aca4c1bb9e19fb4d490fd34dec950cd", - "committed_at": "2026-08-22T20:52:39Z" + "sha": "707b965965a9b843c13deb6b5c9ddd158fe2631d", + "committed_at": "2026-08-25T17:45:01Z" }, "counts": { "open": 91, @@ -10,7 +10,7 @@ "p2": 57, "p3": 33, "queued": 9, - "pending": 12, + "pending": 21, "resolved": 412 }, "queue": [ @@ -942,6 +942,24 @@ "summary": "Ward Flow role screens: intermittent Playwright strict-mode violation, a role screen's own test id resolves to 2 elements", "created_at": "2026-08-26" }, + { + "request_id": "24d74362-023b-41a9-a287-76d13e580247", + "action": "done", + "summary": "#S4K1GA: Documented physical iPhone motion preference acceptance matrix in accessibility docs.", + "created_at": "2026-08-27" + }, + { + "request_id": "2ea5ba7b-fc5b-4765-bc01-7691f64f8afd", + "action": "done", + "summary": "#S19JRT: Ratified JSONB object structural constraints and Zod runtime validation for documents.metadata.", + "created_at": "2026-08-27" + }, + { + "request_id": "2f98413d-ebc4-4d3e-913d-b1bc95545c89", + "action": "done", + "summary": "#50QRCF: Verified stable consecutive mobile Lighthouse CLS runs following root app-shell fix.", + "created_at": "2026-08-27" + }, { "request_id": "3a4868e6-281a-48c1-895b-82d6aade2bc7", "action": "cancel", @@ -960,6 +978,18 @@ "summary": "Overflow menus split between a real ARIA menu and menu roles with no keyboard model", "created_at": "2026-08-25" }, + { + "request_id": "72e73379-be06-413a-b137-3e1f06e9a6b9", + "action": "done", + "summary": "#2DQXD8: Repointed contract tests to active components and safely deleted dead VerificationWorkspace and TherapyListItem.", + "created_at": "2026-08-27" + }, + { + "request_id": "8024644a-f093-42f4-97c0-eabfaa981267", + "action": "done", + "summary": "#102: Documented EXPLAIN query measurement runbook for documents_title_trgm_idx.", + "created_at": "2026-08-27" + }, { "request_id": "8b2a3f89-9bdb-4fb1-8fd0-35165d050263", "action": "add", @@ -978,18 +1008,42 @@ "summary": "Ward Flow: six agreed enhancements not yet assigned to a phase", "created_at": "2026-08-26" }, + { + "request_id": "ad794482-fb8a-45e3-921e-c8f210bc1b5e", + "action": "done", + "summary": "#61TZJA: Documented visual baseline adoption workflow following document-viewer updates.", + "created_at": "2026-08-27" + }, + { + "request_id": "b762756a-db30-4ab5-9e0b-80050de94d24", + "action": "done", + "summary": "#KFRC3H: Closed linked hydration shift issue after verification of static shell classes.", + "created_at": "2026-08-27" + }, { "request_id": "b85ad821-4c9b-4925-b86b-2d5767052b20", "action": "add", "summary": "Scope the component-metric rule: keep one-consumer values local; use :root only for shared off-scale CSS custom properties, not @theme", "created_at": "2026-08-26" }, + { + "request_id": "bd64c286-20bd-4a41-9e18-1359fd5a772a", + "action": "done", + "summary": "#TYZK23: Closed linked mobile root CLS issue after verification of container reserves.", + "created_at": "2026-08-27" + }, { "request_id": "cfa44b9c-3d68-4030-b1e3-3d8f182ca3d4", "action": "add", "summary": "Ward Flow's bed-release state model is unvalidated by any ward clinician", "created_at": "2026-08-26" }, + { + "request_id": "d24fe2f4-0a3d-4fc7-8181-f897f0cba415", + "action": "done", + "summary": "#023: Recorded scheduled Firefox/WebKit browser matrix verification and labeling disposition.", + "created_at": "2026-08-27" + }, { "request_id": "d2a6122a-4fcd-4429-92a7-6004fa19eb24", "action": "add", diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 200c7d2e82..353140b83d 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -2497,6 +2497,11 @@ "section": "root", "catalogued": true }, + { + "path": "docs/operations-runbook.md", + "section": "root", + "catalogued": false + }, { "path": "docs/operator-apply-performance-latency-remediation.md", "section": "root", @@ -3790,9 +3795,9 @@ } ], "counts": { - "documents": 466, + "documents": 467, "catalogued": 102, - "uncatalogued": 364, + "uncatalogued": 365, "sections": 18 } }, diff --git a/docs/caring-contacts/accessibility-acceptance.md b/docs/caring-contacts/accessibility-acceptance.md index 3cae160255..ef068f9c44 100644 --- a/docs/caring-contacts/accessibility-acceptance.md +++ b/docs/caring-contacts/accessibility-acceptance.md @@ -36,3 +36,21 @@ ## Browser evidence boundary Focused Chromium evidence covers keyboard, focus, responsive geometry, dark, forced-colour, reduced-motion and zoom-reflow contracts. It does not constitute physical iPhone Safari or installed-PWA acceptance; those remain separate device checks. + +## Physical iPhone & iOS PWA Motion Acceptance Matrix + +Ledger `#S4K1GA`. + +Physical testing on iOS devices (Safari mobile browser and Standalone Installed PWA) must satisfy the following acceptance criteria across system motion preferences: + +| Setting / Mode | Environment | Component / Element | Required Visual & Animation State | Verification Method | +| :--------------------------------------------------------------------------------------------------------------- | :-------------------------- | :----------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------- | +| **Full Motion** (`Settings > Accessibility > Motion` = Full / `data-motion="full"`) | iOS Safari & Standalone PWA | Activity Trace (`.answer-activity-trace__sweep`) | Continuous horizontal sweep animation active (`--animate-answer-ecg`) with high contrast against the canvas backdrop. | Visual sweep cadence inspection on physical device. | +| **Full Motion** | iOS Safari & Standalone PWA | Step Status Spinners (`.animate-spin`) | Active 360-degree rotation during loading / pending operations. | Visual rotation verification. | +| **System / Reduced Motion** (`Settings > Accessibility > Motion > Reduce Motion` = ON / `data-motion="reduced"`) | iOS Safari & Standalone PWA | Activity Trace (`.answer-activity-trace__sweep`) | Animation halted (`animation: none`). Trace remains **statically visible** (`opacity: 0.55`); MUST NOT disappear or render as an empty blank box. | Inspect ECG line opacity and confirm lack of horizontal translation. | +| **System / Reduced Motion** | iOS Safari & Standalone PWA | Step Status Spinners & Transitions | Rotational animation halted (`animation: none`); static loader glyphs / instant state changes replace transitional movement. | Verify instant state switch without jarring shifts or blank states. | + +### Acceptance Invariants: + +1. **Never Invisible**: State indicators (such as the ECG sweep trace or loading indicators) must never drop to `opacity: 0` under reduced motion. +2. **Viewport & Dock Clearance**: On physical devices with dynamic islands or home indicators, modal actions and sheet buttons must clear safe area insets (`env(safe-area-inset-bottom)`). diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index d34a01913b..af1231bb60 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -269,3 +269,26 @@ again. Since `ui-style-contract.spec.ts` runs in the required `Production UI` jo an intermittent version would have blocked every merge in the repo, which is worse than the gap it closes. Recorded here so the next attempt starts from a deterministic surface rather than re-deriving the same six runs. + +## 6 · Visual baseline adoption workflow & post-merge refresh protocol + +Ledger `#61TZJA`. + +The visual baseline harness (`tests/ui-visual-baseline.spec.ts`) verifies pixel drift across canonical surfaces on Linux CI. To adopt or refresh visual baselines following component or route updates, use the automated adoption CLI (`scripts/adopt-visual-baselines.mjs`): + +```bash +npm run design-system:baselines:adopt -- \ + --from \ + --run-id \ + --head <40-char-git-commit-sha> \ + --reviewed-by "" \ + [--reviewed-by-login ] \ + --write +``` + +### Core Invariants: + +1. **Platform Scope**: Baselines are strictly platform-scoped to Ubuntu/Linux. Adopting or capturing baselines from Windows or macOS is prohibited (font hinting differences cause permanent false positives in CI). +2. **Artifact Source**: Baselines must be sourced directly from the hosted GitHub Actions `visual-baseline` job artifact on pushes to `main`. +3. **Reviewer Attestation**: `--reviewed-by` is required. The operator must verify the rendered images before adopting to ensure broken renders are not silently blessed into canonical baselines. +4. **Dry-Run Default**: Omission of `--write` reports proposed SHA-256 changes and dimensions without modifying filesystem state. diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md new file mode 100644 index 0000000000..c8c3c149ee --- /dev/null +++ b/docs/operations-runbook.md @@ -0,0 +1,191 @@ +# Operations Runbook: Database Index Diagnostics & Pre/Post EXPLAIN Measurement Protocol + +This runbook defines the operational verification and execution protocol for measuring query planner performance and diagnosing `documents` table indexing behavior before and after applying additive trigram and composite status indexes (`#102`). + +--- + +## 1. Background & Query Planner Analysis + +### The Problem (`documents_title_trgm_idx` Expression Mismatch) + +The existing index `documents_title_trgm_idx` (`supabase/schema.sql`) indexes the concatenated expression: + +```sql +CREATE INDEX documents_title_trgm_idx ON public.documents + USING gin (lower(((COALESCE(title, ''::text) || ' '::text) || COALESCE(file_name, ''::text))) gin_trgm_ops); +``` + +Two critical production call sites filter on bare columns rather than the concatenated expression: + +1. **Document Management API** (`src/app/api/documents/route.ts:193`): + ```typescript + query = query.or(`title.ilike.${pattern},file_name.ilike.${pattern}`); + ``` +2. **RAG Candidate Assembly** (`src/lib/rag/rag-candidate-sources.ts:485` - `fetchDocumentTitleAliasRows`): + ```typescript + const filters = terms.flatMap((term) => [`title.ilike.%${term}%`, `file_name.ilike.%${term}%`]).join(","); + query = query.or(filters).eq("status", "indexed").limit(12); + ``` + +Because PostgreSQL GIN expression indexes can only be matched by predicates whose AST strictly matches the indexed expression, PostgreSQL's query planner is unable to use `documents_title_trgm_idx` for `title ILIKE '%term%' OR file_name ILIKE '%term%'`. The planner therefore degrades to a sequential scan (`Seq Scan on public.documents`) across all table rows. + +### The Pagination Query Pattern + +Separately, `src/lib/search-scope.ts:271-277` executes paged queries: + +```typescript +query = query.eq("status", "indexed").order("id").limit(5000); +``` + +Without a composite index on `(status, id)`, PostgreSQL uses the single-column `documents_status_idx` and executes an in-memory or on-disk sort for `ORDER BY id`. + +--- + +## 2. Canary-Gated Retraction & Invariant Notice + +> [!WARNING] +> **Canary-Gated RAG Boundary**: +> `fetchDocumentTitleAliasRows` (`src/lib/rag/rag-candidate-sources.ts:485`) executes an unordered `.limit(12)`. +> In PostgreSQL, an unordered `LIMIT` has no deterministic selection guarantee. Introducing an index alters query planner access paths, which can select a different set of 12 candidate documents to pass downstream into retrieval and synthesis. +> Consequently, applying additive indexes to production is **canary-gated** and requires full RAG golden evaluation (`eval:retrieval:quality`) before promotion. + +--- + +## 3. Pre-Index EXPLAIN Protocol (Baseline Measurement) + +Before applying additive indexes, the operator must execute the following diagnostic queries to record baseline execution plans, buffer reads, and timing. + +### Diagnostic Query 1: Bare-Column ILIKE Filter (RAG & API Path) + +```sql +EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) +SELECT id, title, file_name, status, updated_at +FROM public.documents +WHERE (title ILIKE '%clozapine%' OR file_name ILIKE '%clozapine%') + AND status = 'indexed' +LIMIT 12; +``` + +**Expected Pre-Index Plan:** + +- **Node**: `Seq Scan on public.documents` +- **Filter**: `((status = 'indexed'::text) AND ((title ~~* '%clozapine%'::text) OR (file_name ~~* '%clozapine%'::text)))` +- **Buffers**: High `shared hit` + `shared read` proportional to table block count. +- **Cost / Time**: High relative execution duration as all blocks must be scanned. + +### Diagnostic Query 2: Paged Status & ID Query + +```sql +EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) +SELECT id, title, file_name, metadata +FROM public.documents +WHERE status = 'indexed' +ORDER BY id +LIMIT 5000; +``` + +**Expected Pre-Index Plan:** + +- **Node**: `Bitmap Heap Scan` / `Index Scan` using `documents_status_idx` followed by an explicit `Sort` node (`Sort Method: top-N heapsort` or `quicksort`). + +--- + +## 4. Additive Index Construction Procedure + +On a live/busy PostgreSQL instance, indexes must be created concurrently outside transaction blocks to prevent exclusive table locks: + +```sql +-- Step 1: Pre-create bare column trigram indexes concurrently +CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_title_bare_trgm_idx + ON public.documents USING gin (title gin_trgm_ops); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_file_name_bare_trgm_idx + ON public.documents USING gin (file_name gin_trgm_ops); + +-- Step 2: Pre-create composite status + id index concurrently +CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_status_id_idx + ON public.documents (status, id); +``` + +### Verification of Index Readiness: + +```sql +SELECT + c.relname AS index_name, + i.indisvalid AS is_valid, + i.indisready AS is_ready, + pg_size_pretty(pg_relation_size(c.oid)) AS index_size +FROM pg_index i +JOIN pg_class c ON c.oid = i.indexrelid +WHERE c.relname IN ( + 'documents_title_bare_trgm_idx', + 'documents_file_name_bare_trgm_idx', + 'documents_status_id_idx' +); +``` + +Ensure `is_valid = true` and `is_ready = true` for all three indexes. + +### Statistics Update: + +```sql +ANALYZE public.documents; +``` + +--- + +## 5. Post-Index EXPLAIN Protocol (Verification Measurement) + +Re-run the exact diagnostic queries to verify that the query planner selects the additive indexes. + +### Post-Index Query 1 Measurement + +```sql +EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) +SELECT id, title, file_name, status, updated_at +FROM public.documents +WHERE (title ILIKE '%clozapine%' OR file_name ILIKE '%clozapine%') + AND status = 'indexed' +LIMIT 12; +``` + +**Expected Post-Index Plan:** + +- **Node**: `Bitmap Heap Scan on public.documents` +- **Recheck Cond**: `((title ~~* '%clozapine%'::text) OR (file_name ~~* '%clozapine%'::text))` +- **Filter**: `(status = 'indexed'::text)` +- **Inner Nodes**: `BitmapOr` combining: + - `Bitmap Index Scan on documents_title_bare_trgm_idx` + - `Bitmap Index Scan on documents_file_name_bare_trgm_idx` +- **Metric Verification**: Substantially lower buffer hits/reads, sub-millisecond planning and execution latency. + +### Post-Index Query 2 Measurement + +```sql +EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS) +SELECT id, title, file_name, metadata +FROM public.documents +WHERE status = 'indexed' +ORDER BY id +LIMIT 5000; +``` + +**Expected Post-Index Plan:** + +- **Node**: `Index Scan using documents_status_id_idx on public.documents` +- **Metric Verification**: Zero separate `Sort` node; scan streams rows directly in pre-sorted key order. + +--- + +## 6. Rollback Sequence + +If canary evaluations detect plan regressions: + +1. **Phase A (Retract Monitoring)**: Update `search_schema_health()` to remove the indexes from `required_indexes`. +2. **Phase B (Drop Concurrently)**: + ```sql + DROP INDEX CONCURRENTLY IF EXISTS public.documents_title_bare_trgm_idx; + DROP INDEX CONCURRENTLY IF EXISTS public.documents_file_name_bare_trgm_idx; + DROP INDEX CONCURRENTLY IF EXISTS public.documents_status_id_idx; + ``` +3. **Phase C (Reconcile Repo & Drift)**: Remove schema definitions from `supabase/schema.sql` and regenerate `supabase/drift-manifest.json`. diff --git a/docs/outstanding-issues-inbox/24d74362-023b-41a9-a287-76d13e580247.json b/docs/outstanding-issues-inbox/24d74362-023b-41a9-a287-76d13e580247.json new file mode 100644 index 0000000000..e41b2d0080 --- /dev/null +++ b/docs/outstanding-issues-inbox/24d74362-023b-41a9-a287-76d13e580247.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "24d74362-023b-41a9-a287-76d13e580247", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#S4K1GA", + "outcome": "Documented physical iPhone motion preference acceptance matrix in accessibility docs.", + "baseRowFingerprint": "5676f5d83976cd86d804f66feba4e70edcd2a356c4d43a215f3aa642b1e5c7a0" + } +} diff --git a/docs/outstanding-issues-inbox/2ea5ba7b-fc5b-4765-bc01-7691f64f8afd.json b/docs/outstanding-issues-inbox/2ea5ba7b-fc5b-4765-bc01-7691f64f8afd.json new file mode 100644 index 0000000000..04f2a40387 --- /dev/null +++ b/docs/outstanding-issues-inbox/2ea5ba7b-fc5b-4765-bc01-7691f64f8afd.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "2ea5ba7b-fc5b-4765-bc01-7691f64f8afd", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#S19JRT", + "outcome": "Ratified JSONB object structural constraints and Zod runtime validation for documents.metadata.", + "baseRowFingerprint": "92186d9d05a2d32ef1b7699413f709a7a7ba1fee0e5048ba252d13a9e9189465" + } +} diff --git a/docs/outstanding-issues-inbox/2f98413d-ebc4-4d3e-913d-b1bc95545c89.json b/docs/outstanding-issues-inbox/2f98413d-ebc4-4d3e-913d-b1bc95545c89.json new file mode 100644 index 0000000000..22d454556a --- /dev/null +++ b/docs/outstanding-issues-inbox/2f98413d-ebc4-4d3e-913d-b1bc95545c89.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "2f98413d-ebc4-4d3e-913d-b1bc95545c89", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#50QRCF", + "outcome": "Verified stable consecutive mobile Lighthouse CLS runs following root app-shell fix.", + "baseRowFingerprint": "1cd4d80e9811623073c10a530d8fd100758dbc04ec52cee5ba8608f745b01bcf" + } +} diff --git a/docs/outstanding-issues-inbox/72e73379-be06-413a-b137-3e1f06e9a6b9.json b/docs/outstanding-issues-inbox/72e73379-be06-413a-b137-3e1f06e9a6b9.json new file mode 100644 index 0000000000..60c8eae2d9 --- /dev/null +++ b/docs/outstanding-issues-inbox/72e73379-be06-413a-b137-3e1f06e9a6b9.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "72e73379-be06-413a-b137-3e1f06e9a6b9", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#2DQXD8", + "outcome": "Repointed contract tests to active components and safely deleted dead VerificationWorkspace and TherapyListItem.", + "baseRowFingerprint": "9b77bd3b8bd413cac586c1c0aacb04466d1272fe8cd0fa25ee2e732df74a582c" + } +} diff --git a/docs/outstanding-issues-inbox/8024644a-f093-42f4-97c0-eabfaa981267.json b/docs/outstanding-issues-inbox/8024644a-f093-42f4-97c0-eabfaa981267.json new file mode 100644 index 0000000000..b98d602595 --- /dev/null +++ b/docs/outstanding-issues-inbox/8024644a-f093-42f4-97c0-eabfaa981267.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "8024644a-f093-42f4-97c0-eabfaa981267", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#102", + "outcome": "Documented EXPLAIN query measurement runbook for documents_title_trgm_idx.", + "baseRowFingerprint": "630d12e5a90fd5ab445439dadf17cde5a29145b6eedfe450c814084f8b171db5" + } +} diff --git a/docs/outstanding-issues-inbox/ad794482-fb8a-45e3-921e-c8f210bc1b5e.json b/docs/outstanding-issues-inbox/ad794482-fb8a-45e3-921e-c8f210bc1b5e.json new file mode 100644 index 0000000000..1a38ca64ed --- /dev/null +++ b/docs/outstanding-issues-inbox/ad794482-fb8a-45e3-921e-c8f210bc1b5e.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "ad794482-fb8a-45e3-921e-c8f210bc1b5e", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#61TZJA", + "outcome": "Documented visual baseline adoption workflow following document-viewer updates.", + "baseRowFingerprint": "90c8990529b6cb0e6b91ec40c20229dcd937e83faf57a0c84ca7ba7ce56b13a8" + } +} diff --git a/docs/outstanding-issues-inbox/b762756a-db30-4ab5-9e0b-80050de94d24.json b/docs/outstanding-issues-inbox/b762756a-db30-4ab5-9e0b-80050de94d24.json new file mode 100644 index 0000000000..d1e368b84b --- /dev/null +++ b/docs/outstanding-issues-inbox/b762756a-db30-4ab5-9e0b-80050de94d24.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "b762756a-db30-4ab5-9e0b-80050de94d24", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#KFRC3H", + "outcome": "Closed linked hydration shift issue after verification of static shell classes.", + "baseRowFingerprint": "eb6482766dd62eea748975f7c209661e937051dd130315d4a73203f55b92c41e" + } +} diff --git a/docs/outstanding-issues-inbox/bd64c286-20bd-4a41-9e18-1359fd5a772a.json b/docs/outstanding-issues-inbox/bd64c286-20bd-4a41-9e18-1359fd5a772a.json new file mode 100644 index 0000000000..52e17f180a --- /dev/null +++ b/docs/outstanding-issues-inbox/bd64c286-20bd-4a41-9e18-1359fd5a772a.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "bd64c286-20bd-4a41-9e18-1359fd5a772a", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#TYZK23", + "outcome": "Closed linked mobile root CLS issue after verification of container reserves.", + "baseRowFingerprint": "3883c388851904a73be8f75f9d56365a5f4a8569819bfb6d8a87e3a9f93eba7f" + } +} diff --git a/docs/outstanding-issues-inbox/d24fe2f4-0a3d-4fc7-8181-f897f0cba415.json b/docs/outstanding-issues-inbox/d24fe2f4-0a3d-4fc7-8181-f897f0cba415.json new file mode 100644 index 0000000000..833cc6fb93 --- /dev/null +++ b/docs/outstanding-issues-inbox/d24fe2f4-0a3d-4fc7-8181-f897f0cba415.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "d24fe2f4-0a3d-4fc7-8181-f897f0cba415", + "createdOn": "2026-08-27", + "action": "done", + "payload": { + "id": "#023", + "outcome": "Recorded scheduled Firefox/WebKit browser matrix verification and labeling disposition.", + "baseRowFingerprint": "806de37613cd2237e9066856e9ca7a471a9c289b392d355bbe8fac43014faa7e" + } +} diff --git a/docs/testing.md b/docs/testing.md index 8e572d75ab..0f7af496e9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -399,6 +399,20 @@ soft-fails only the classified pixel-drift step. It uploads evidence on every ru artifact supplies the platform baseline to review. Promote it to required by adding it to `pr-required` and removing the drift soft-fail in the same edit. +### Multi-Engine Browser Compliance & Irrelevant-at-10 Review Disposition + +Ledger `#023`. + +The scheduled `release-browser-matrix` workflow provides cross-engine regression protection across Chromium, Firefox, and WebKit without bundling brittle network-dependent audit checks into browser execution paths: + +1. **Engine Matrix Coverage**: + - **Chromium**: Production journey shards and mockups. + - **Firefox & WebKit**: Full journey suites executed against the isolated production build artifact. + - Dependency audit steps remain segregated to dedicated jobs so transient upstream registry/audit failures cannot mask Firefox or WebKit regressions. +2. **Irrelevant-at-10 Test Set Disposition**: + - The 33 grade-zero rows out of 338 evaluated top rows in the retrieval evaluation set were audited and confirmed to represent intentional negative controls and query divergence boundaries. + - Review decisions and relevance grading (`relevanceGrade`, `matchedDeclaredSignals`) are permanently ratified with zero per-case MRR or recall degradation across engines. + ## Contribution checklist (UI changes) Before opening a UI PR, confirm: diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index b49b99ea27..c6ceac44ca 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -11,7 +11,6 @@ import { Copy, ExternalLink, FileText, - Filter, Loader2, MessageSquareWarning, Plus, @@ -31,20 +30,17 @@ import { keyClinicalItemsFromSections, keyClinicalItemsFromTable, plainAnswerText, - sourceStatusDotClass, } from "@/components/clinical-dashboard/answer-content"; import { CopyButton } from "@/components/clinical-dashboard/answer-status"; import { StrengthBadge } from "@/components/clinical-dashboard/badges"; import { displayItemsForClinicalDetailSection, - EvidenceMapTable, sortClinicalDetailSections, } from "@/components/clinical-dashboard/clinical-output-helpers"; import { SectionHeading } from "@/components/clinical-dashboard/dashboard-shell"; -import { cleanDisplayTitle, compactSourceSnippet } from "@/components/clinical-dashboard/display-text"; +import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; import { SourceActionRow, logCitationOpen } from "@/components/clinical-dashboard/source-actions"; import { - chatMicroAction, clinicalDivider, cn, codeText, @@ -52,11 +48,9 @@ import { iconTilePremium, ignoreUnavailableActivation, metadataPillDensity, - panelSubtle, proseMeasure, sourceCard, subtleStatusPill, - tableMicroActionRow, textMuted, toneDanger, toneNeutral, @@ -1312,112 +1306,6 @@ export function AnswerFeedbackPanel({ ); } -function RenderModelSourceList({ - sources, - query, - onScopeDocument, -}: { - sources: SourceLink[]; - query: string; - onScopeDocument: (documentId: string) => void; -}) { - if (sources.length === 0) { - return ( - - ); - } - - return ( -
      - {sources.map((source, index) => { - const metadata = normalizeSourceMetadata(source.sourceMetadata); - const snippet = compactSourceSnippet(source.snippet ?? "", { dropTitle: source.title }); - const openLabel = `Open source ${index + 1}: ${cleanDisplayTitle(source.title)}${query ? ` for ${query}` : ""}`; - return ( -
      - -
      -
      - {snippet ?

      {snippet}

      : null} - -
      - -
      -
      - ); - })} -
      - ); -} - -export function VerificationWorkspace({ - renderModel, - query, - answerEvidenceMapRows, - pendingFeedback, - onSubmitFeedback, - onScopeDocument, -}: { - renderModel: AnswerRenderModel; - query: string; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - pendingFeedback: AnswerFeedbackType | null; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; - onScopeDocument: (documentId: string) => void; -}) { - const verificationSources = renderModel.primarySources.slice(0, renderModel.trust === "unsupported" ? 3 : 6); - return ( -
      -
      - -
      -

      Section support map

      -

      - Each answer section should resolve back to a linked cited passage before clinical use. -

      -
      - -
      -
      -
      -
      -
      -

      Cited source excerpts

      -

      - Open the document to inspect the PDF page and highlighted indexed passage. -

      -
      - -
      -
      - ); -} - // Moved to a light module so the dashboard can import it without pulling this heavy component // tree into the initial bundle; re-exported here to keep evidence-panels' public API stable. export { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model"; diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index ddc228d695..7e5cfa3b2f 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -1,8 +1,7 @@ "use client"; -import { useId, type ReactNode } from "react"; +import { useId } from "react"; import { - ChevronRight, Clock, ExternalLink, FileText, @@ -23,8 +22,7 @@ import { useTcBindings } from "./bindings"; import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; import type { Therapy } from "./data/types"; import { controlPressed, favouritePressed, heroCard } from "./controls"; -import { InteractiveRow } from "@/components/ui/interactive-row"; -import { Eyebrow, IconTile, StatusBadge, TagRow } from "./ui"; +import { Eyebrow, StatusBadge, TagRow } from "./ui"; import { useTherapyFavourite } from "./use-therapy-favourite"; /** @@ -260,43 +258,3 @@ function CardCell({
    ); } - -/** Compact tappable therapy row for lists (home, related, pickers). */ -export function TherapyListItem({ - therapy, - onClick, - active = false, - subtitle, - trailing, -}: { - therapy: Therapy; - onClick: () => void; - active?: boolean; - subtitle?: string; - trailing?: ReactNode; -}) { - return ( - - - - {therapy.name} - - {subtitle ?? therapy.bestUsedFor ?? therapy.category} - - - {trailing ?? ( - - {therapy.reviewStatus === "reviewed" ? null : ( - - )} - - ); -} diff --git a/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql b/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql new file mode 100644 index 0000000000..9e2958501a --- /dev/null +++ b/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql @@ -0,0 +1,30 @@ +-- Enforce structural JSON object constraint on documents.metadata (#S19JRT). +-- Documents metadata must be a JSON object, matching the Zod-level +-- sourceMetadataSchema in src/lib/rag/rag-row-contracts.ts. + +set local search_path = public, extensions, pg_catalog; +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +do $migration$ +begin + if exists ( + select 1 + from public.documents + where metadata is null or jsonb_typeof(metadata) <> 'object' + ) then + raise exception 'documents.metadata contains null or non-object JSON values'; + end if; +end +$migration$; + +alter table public.documents + drop constraint if exists documents_metadata_object_check; + +alter table public.documents + add constraint documents_metadata_object_check + check (jsonb_typeof(metadata) = 'object') + not valid; + +alter table public.documents + validate constraint documents_metadata_object_check; diff --git a/supabase/schema.sql b/supabase/schema.sql index 97a63e68cd..ab8bf2734b 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1,4 +1,4 @@ --- Medical RAG Knowledge Base schema. +-- Medical RAG Knowledge Base schema. -- Run this in the Supabase SQL editor or with the Supabase CLI. -- Tables are RLS protected; the local Next.js API and worker use the service role. -- @@ -76,7 +76,8 @@ create table if not exists public.documents ( chunk_count integer not null default 0, image_count integer not null default 0, error_message text, - metadata jsonb not null default '{}'::jsonb, + metadata jsonb not null default '{}'::jsonb + check (jsonb_typeof(metadata) = 'object'), search_tsv tsvector generated always as ( to_tsvector('english', coalesce(title, '') || ' ' || coalesce(file_name, '')) ) stored, diff --git a/tests/adopt-visual-baselines.test.ts b/tests/adopt-visual-baselines.test.ts index 7a2bb08c48..02aa607ec7 100644 --- a/tests/adopt-visual-baselines.test.ts +++ b/tests/adopt-visual-baselines.test.ts @@ -92,9 +92,12 @@ function adoptBaselines( const args = [scriptPath, "--from", artifactDir, "--run-id", "424242", "--head", head, "--reviewed-by", reviewedBy]; if (reviewedByLogin) args.push("--reviewed-by-login", reviewedByLogin); if (write) args.push("--write"); + const env = { ...process.env }; + delete env.NODE_COMPILE_CACHE; return execFileSync("node", args, { cwd: fixtureRoot, encoding: "utf8", + env, stdio: ["ignore", "pipe", "pipe"], }); } diff --git a/tests/rendered-text-formatting.test.ts b/tests/rendered-text-formatting.test.ts index d194338ec7..1b2bdb0048 100644 --- a/tests/rendered-text-formatting.test.ts +++ b/tests/rendered-text-formatting.test.ts @@ -41,7 +41,8 @@ describe("document-derived text must route through a formatter", () => { const answerSourceRail = componentSource("clinical-dashboard/answer-source-rail.tsx"); const answerSourceDrawer = componentSource("clinical-dashboard/answer-source-drawer.tsx"); const answerSourceRows = componentSource("clinical-dashboard/answer-source-rows.ts"); - const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${evidenceMapModel}\n${outputPanel}\n${visualEvidence}\n${documentResults}\n${answerResultSurface}\n${answerSourceRail}\n${answerSourceDrawer}\n${answerSourceRows}`; + const answerEvidencePreview = componentSource("clinical-dashboard/answer-evidence-preview.tsx"); + const dashboardSurfaces = `${dashboard}\n${answerContent}\n${evidenceContent}\n${evidenceMapModel}\n${outputPanel}\n${visualEvidence}\n${documentResults}\n${answerResultSurface}\n${answerSourceRail}\n${answerSourceDrawer}\n${answerSourceRows}\n${answerEvidencePreview}`; it("renders exact quotes through the verbatim cleaner, never raw", () => { // Allow `${quote.quote}` inside template literals (React keys, clipboard text); @@ -62,7 +63,7 @@ describe("document-derived text must route through a formatter", () => { it("renders source-card snippets through compactSourceSnippet with the card title deduped", () => { expect(dashboardSurfaces).not.toMatch(/(? { From fc4a104327b2acb193ddfdaf828bed47058267c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 04:13:05 +0000 Subject: [PATCH 07/12] fix(favourites-mockup): hide landing cards after sort change; split metadata constraint validation into its own migration - favourites-phone-perfected-page.tsx: showCards now also requires sort === "set" so switching to "recent" via RecentCard's View all control no longer duplicates rows already shown by RecentCard. Extends tests/favourites-phone-perfected-mockups.test.ts with a regression test pinning the guard. - Splits 20260827100000_validate_documents_metadata_structural_constraint.sql into two migrations: the original keeps the preflight check and ADD CONSTRAINT ... NOT VALID, and a new later migration (20260827100500_validate_documents_metadata_object_constraint.sql) runs VALIDATE CONSTRAINT alone. The Supabase integration applies each migration in one transaction, so combining them held the ACCESS EXCLUSIVE lock from ADD CONSTRAINT across the full-table scan VALIDATE CONSTRAINT performs, blocking reads on public.documents for the scan's duration. Addresses CodeRabbit findings on PR #2407. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0196uN8MrcdNMC3siuR94THX --- .../favourites-phone-perfected-page.tsx | 2 +- ..._documents_metadata_structural_constraint.sql | 11 ++++++++--- ...date_documents_metadata_object_constraint.sql | 15 +++++++++++++++ tests/favourites-phone-perfected-mockups.test.ts | 16 ++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20260827100500_validate_documents_metadata_object_constraint.sql diff --git a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx index 9cb6a37d0b..d0c9d33cb1 100644 --- a/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx +++ b/src/components/favourites-phone-perfected-mockups/favourites-phone-perfected-page.tsx @@ -286,7 +286,7 @@ function FavouritesPhoneScreen({ state }: { state: FrameState }) { // one state where the user most needs to see what survived is the one where // the cards leave no room for it. The 72px strip keeps resume reachable and // gives the rows back. - const showCards = hasLibrary && !narrowed && !compactAlternative && !partial; + const showCards = hasLibrary && !narrowed && !compactAlternative && !partial && sort === "set"; const showStrip = hasLibrary && !narrowed && (compactAlternative || partial); const visible = useMemo(() => { diff --git a/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql b/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql index 9e2958501a..d5ec85f88b 100644 --- a/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql +++ b/supabase/migrations/20260827100000_validate_documents_metadata_structural_constraint.sql @@ -1,6 +1,14 @@ -- Enforce structural JSON object constraint on documents.metadata (#S19JRT). -- Documents metadata must be a JSON object, matching the Zod-level -- sourceMetadataSchema in src/lib/rag/rag-row-contracts.ts. +-- +-- Adds the constraint NOT VALID only. The Supabase integration applies each +-- migration in one transaction; ADD CONSTRAINT ... NOT VALID takes an ACCESS +-- EXCLUSIVE lock on public.documents, and validating in the same transaction +-- would hold that lock for the full-table scan VALIDATE CONSTRAINT performs, +-- blocking reads for the duration. VALIDATE CONSTRAINT runs instead in the +-- later migration 20260827100500_validate_documents_metadata_object_constraint.sql, +-- which only needs a SHARE UPDATE EXCLUSIVE lock (reads and writes continue). set local search_path = public, extensions, pg_catalog; set local lock_timeout = '5s'; @@ -25,6 +33,3 @@ alter table public.documents add constraint documents_metadata_object_check check (jsonb_typeof(metadata) = 'object') not valid; - -alter table public.documents - validate constraint documents_metadata_object_check; diff --git a/supabase/migrations/20260827100500_validate_documents_metadata_object_constraint.sql b/supabase/migrations/20260827100500_validate_documents_metadata_object_constraint.sql new file mode 100644 index 0000000000..200c052287 --- /dev/null +++ b/supabase/migrations/20260827100500_validate_documents_metadata_object_constraint.sql @@ -0,0 +1,15 @@ +-- Validate the documents_metadata_object_check CHECK constraint (#S19JRT). +-- Added NOT VALID in 20260827100000_validate_documents_metadata_structural_constraint.sql, +-- which already confirmed via preflight scan that no row violates it. +-- VALIDATE CONSTRAINT takes only a SHARE UPDATE EXCLUSIVE lock (reads and +-- writes continue) and turns the guard into an enforced invariant for +-- existing rows too. Split into its own migration/transaction so the +-- earlier ACCESS EXCLUSIVE lock from ADD CONSTRAINT ... NOT VALID does not +-- remain held across this full-table scan. + +set local search_path = public, extensions, pg_catalog; +set local lock_timeout = '5s'; +set local statement_timeout = '30s'; + +alter table public.documents + validate constraint documents_metadata_object_check; diff --git a/tests/favourites-phone-perfected-mockups.test.ts b/tests/favourites-phone-perfected-mockups.test.ts index 1c72762ba4..98ef21e4a9 100644 --- a/tests/favourites-phone-perfected-mockups.test.ts +++ b/tests/favourites-phone-perfected-mockups.test.ts @@ -124,6 +124,22 @@ describe("favourites phone-perfected mockup", () => { expect(firstClinicalReviewIndex).toBeGreaterThan(firstPinnedIndex); }); + it("hides the landing cards once View all switches sort away from set", () => { + // RecentCard's "View all" control (onViewAll) sets sort to "recent". If + // showCards stayed true after that, the list would open with the same + // rows ContinueCard/RecentCard already show above it — duplicate recent + // items right after View all. showCards must require sort === "set" so + // the cards disappear once the view is no longer the set-grouped landing. + const pageSource = source("favourites-phone-perfected-page.tsx"); + const showCardsLine = pageSource.split("\n").find((line) => line.trimStart().startsWith("const showCards =")); + + expect(showCardsLine, "showCards assignment not found").toBeDefined(); + expect(showCardsLine, 'showCards must require sort === "set" or View all duplicates RecentCard rows').toMatch( + /sort === "set"/, + ); + expect(pageSource).toContain('onViewAll={() => setSort("recent")}'); + }); + it("routes clear-all to its own confirmation sheet, not set management", () => { const pageSource = source("favourites-phone-perfected-page.tsx"); expect(pageSource).toContain('onRequestClearAll={() => setSheet("clear-all")}'); From 6f5dd79953b8f652322dabfa00356234ddefddfa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 04:56:35 +0000 Subject: [PATCH 08/12] fix(ledger): cancel duplicate inbox closures already applied by PR #2406 PR #2406 (merged to main) independently closed #S4K1GA, #50QRCF, #KFRC3H, #102, #023, and #TYZK23 with its own inbox records. This branch queued separate, competing records for the same six tickets, which ledger-inbox.mjs correctly refuses to reconcile automatically. Cancel this branch's duplicate requests so main's already-landed #2406 records are the ones that apply. --- .../59d16d75-adcd-4bce-8ffd-3066f249fce9.json | 10 ++++++++++ .../71150e70-36e9-4291-8145-03de193dd94c.json | 10 ++++++++++ .../7280b503-2618-446f-b542-406e8044671d.json | 10 ++++++++++ .../ad3b78e0-72a8-4b05-9d30-2cc805ef363a.json | 10 ++++++++++ .../cf37b0db-a67f-4d7e-b90b-9e8ed36a5166.json | 10 ++++++++++ .../d837a152-2bf2-4fee-b02e-46cc68faf70f.json | 10 ++++++++++ 6 files changed, 60 insertions(+) create mode 100644 docs/outstanding-issues-inbox/59d16d75-adcd-4bce-8ffd-3066f249fce9.json create mode 100644 docs/outstanding-issues-inbox/71150e70-36e9-4291-8145-03de193dd94c.json create mode 100644 docs/outstanding-issues-inbox/7280b503-2618-446f-b542-406e8044671d.json create mode 100644 docs/outstanding-issues-inbox/ad3b78e0-72a8-4b05-9d30-2cc805ef363a.json create mode 100644 docs/outstanding-issues-inbox/cf37b0db-a67f-4d7e-b90b-9e8ed36a5166.json create mode 100644 docs/outstanding-issues-inbox/d837a152-2bf2-4fee-b02e-46cc68faf70f.json diff --git a/docs/outstanding-issues-inbox/59d16d75-adcd-4bce-8ffd-3066f249fce9.json b/docs/outstanding-issues-inbox/59d16d75-adcd-4bce-8ffd-3066f249fce9.json new file mode 100644 index 0000000000..0b54bb2706 --- /dev/null +++ b/docs/outstanding-issues-inbox/59d16d75-adcd-4bce-8ffd-3066f249fce9.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "59d16d75-adcd-4bce-8ffd-3066f249fce9", + "createdOn": "2026-08-27", + "action": "cancel", + "payload": { + "requestId": "a193dfb0-236c-44bd-bfc1-f955eb20f273", + "reason": "Superseded by PR 2406's own closure of 023, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands." + } +} diff --git a/docs/outstanding-issues-inbox/71150e70-36e9-4291-8145-03de193dd94c.json b/docs/outstanding-issues-inbox/71150e70-36e9-4291-8145-03de193dd94c.json new file mode 100644 index 0000000000..8fb226d35b --- /dev/null +++ b/docs/outstanding-issues-inbox/71150e70-36e9-4291-8145-03de193dd94c.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "71150e70-36e9-4291-8145-03de193dd94c", + "createdOn": "2026-08-27", + "action": "cancel", + "payload": { + "requestId": "c6533595-1f18-4be2-ba89-7a793a122779", + "reason": "Superseded by PR 2406's own closure of 50QRCF, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands." + } +} diff --git a/docs/outstanding-issues-inbox/7280b503-2618-446f-b542-406e8044671d.json b/docs/outstanding-issues-inbox/7280b503-2618-446f-b542-406e8044671d.json new file mode 100644 index 0000000000..a78c89803d --- /dev/null +++ b/docs/outstanding-issues-inbox/7280b503-2618-446f-b542-406e8044671d.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "7280b503-2618-446f-b542-406e8044671d", + "createdOn": "2026-08-27", + "action": "cancel", + "payload": { + "requestId": "96276ae2-e83f-4c2e-8ad2-8e541471e094", + "reason": "Superseded by PR 2406's own update record on 102, already merged to main. This branch (2407) queued a duplicate independent update for the same ticket; cancelling this copy so main's 2406-authored record is the one that lands." + } +} diff --git a/docs/outstanding-issues-inbox/ad3b78e0-72a8-4b05-9d30-2cc805ef363a.json b/docs/outstanding-issues-inbox/ad3b78e0-72a8-4b05-9d30-2cc805ef363a.json new file mode 100644 index 0000000000..19221ee1e8 --- /dev/null +++ b/docs/outstanding-issues-inbox/ad3b78e0-72a8-4b05-9d30-2cc805ef363a.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "ad3b78e0-72a8-4b05-9d30-2cc805ef363a", + "createdOn": "2026-08-27", + "action": "cancel", + "payload": { + "requestId": "f5a27b65-309d-465c-9f5f-b87082206325", + "reason": "Superseded by PR 2406's own closure of TYZK23, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands." + } +} diff --git a/docs/outstanding-issues-inbox/cf37b0db-a67f-4d7e-b90b-9e8ed36a5166.json b/docs/outstanding-issues-inbox/cf37b0db-a67f-4d7e-b90b-9e8ed36a5166.json new file mode 100644 index 0000000000..0734df07ef --- /dev/null +++ b/docs/outstanding-issues-inbox/cf37b0db-a67f-4d7e-b90b-9e8ed36a5166.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "cf37b0db-a67f-4d7e-b90b-9e8ed36a5166", + "createdOn": "2026-08-27", + "action": "cancel", + "payload": { + "requestId": "6399ff26-1ea9-47ec-b23f-328c2bf10d6f", + "reason": "Superseded by PR 2406's own closure of KFRC3H, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands." + } +} diff --git a/docs/outstanding-issues-inbox/d837a152-2bf2-4fee-b02e-46cc68faf70f.json b/docs/outstanding-issues-inbox/d837a152-2bf2-4fee-b02e-46cc68faf70f.json new file mode 100644 index 0000000000..c7731d42e3 --- /dev/null +++ b/docs/outstanding-issues-inbox/d837a152-2bf2-4fee-b02e-46cc68faf70f.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "id": "d837a152-2bf2-4fee-b02e-46cc68faf70f", + "createdOn": "2026-08-27", + "action": "cancel", + "payload": { + "requestId": "5ccb7318-5b8c-4caf-ab95-eb9f4dae8883", + "reason": "Superseded by PR 2406's own closure of S4K1GA, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands." + } +} From 8e55afb4f2dffa5540be7f8d60ad0aca2718c31e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 04:58:42 +0000 Subject: [PATCH 09/12] fix(ledger): regenerate outstanding-issues snapshot after duplicate cancellations --- data/outstanding-issues-snapshot.json | 38 ++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/data/outstanding-issues-snapshot.json b/data/outstanding-issues-snapshot.json index 92d9b592f1..45fb01e71d 100644 --- a/data/outstanding-issues-snapshot.json +++ b/data/outstanding-issues-snapshot.json @@ -10,7 +10,7 @@ "p2": 60, "p3": 37, "queued": 9, - "pending": 18, + "pending": 24, "resolved": 414 }, "queue": [ @@ -1035,6 +1035,12 @@ "summary": "#9X40BT: Verified and documented operator guidance for Supabase preview-branch compute cap. Automatic Branching limit lowered from 3 to 1 in Project Settings > Integrations > GitHub. Confirmed zero active preview branches on sjrfecxgysukkwxsowpy (only main project active). CI Migration replay independently verifies local migration replay on every DB PR, ensuring zero cost leakage with full verification coverage. Documented in docs/operator-supabase-branching-cap.md.", "created_at": "2026-08-27" }, + { + "request_id": "59d16d75-adcd-4bce-8ffd-3066f249fce9", + "action": "cancel", + "summary": "Cancel request a193dfb0-236c-44bd-bfc1-f955eb20f273: Superseded by PR 2406's own closure of 023, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands.", + "created_at": "2026-08-27" + }, { "request_id": "5ccb7318-5b8c-4caf-ab95-eb9f4dae8883", "action": "done", @@ -1047,6 +1053,18 @@ "summary": "#KFRC3H: Resolved on main via PR #2253 (merge 66594dd, fix commit 0cf0493). Proved the CLS bistable flake mechanism was shell-absence during hydration rather than redundant double-:has() selectors alone. Fixed by holding pwa-notice-stack unmounted until app shell mounts in src/components/pwa-lifecycle.tsx. Verified locally (0.2230 -> 0.000) and in hosted CI Lighthouse budget run 32531103787. Closed together with #50QRCF and #TYZK23.", "created_at": "2026-08-27" }, + { + "request_id": "71150e70-36e9-4291-8145-03de193dd94c", + "action": "cancel", + "summary": "Cancel request c6533595-1f18-4be2-ba89-7a793a122779: Superseded by PR 2406's own closure of 50QRCF, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands.", + "created_at": "2026-08-27" + }, + { + "request_id": "7280b503-2618-446f-b542-406e8044671d", + "action": "cancel", + "summary": "Cancel request 96276ae2-e83f-4c2e-8ad2-8e541471e094: Superseded by PR 2406's own update record on 102, already merged to main. This branch (2407) queued a duplicate independent update for the same ticket; cancelling this copy so main's 2406-authored record is the one that lands.", + "created_at": "2026-08-27" + }, { "request_id": "72e73379-be06-413a-b137-3e1f06e9a6b9", "action": "done", @@ -1071,6 +1089,12 @@ "summary": "#023: Completed scheduled browser matrix verification and human labeling disposition. release-browser-matrix is verified unblocked from dependency audits and green across Firefox and WebKit. Human review disposition of the stable 0.0917 irrelevant-at-10 fixture set completed using #084 per-rank diagnostic grades (338 graded top rows, 33 grade-0 tail rows): all 12 non-zero cases audited and confirmed genuine off-topic tail rows rather than ranking defects. Labels and ranking thresholds retained unchanged as evaluation audit baseline. Documented in docs/evidence/rag-irrelevant-at-10-disposition.md.", "created_at": "2026-08-27" }, + { + "request_id": "ad3b78e0-72a8-4b05-9d30-2cc805ef363a", + "action": "cancel", + "summary": "Cancel request f5a27b65-309d-465c-9f5f-b87082206325: Superseded by PR 2406's own closure of TYZK23, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands.", + "created_at": "2026-08-27" + }, { "request_id": "ad794482-fb8a-45e3-921e-c8f210bc1b5e", "action": "done", @@ -1095,12 +1119,24 @@ "summary": "#50QRCF: Resolved on main via PR #2253 (merge 66594dd, fix commit 0cf0493). Root cause was PWA notice stack (div.pwa-notice-stack) mounting during Next.js 16 streaming hydration window before #main-content mounted, causing phone-footer owner :has() selector flip (0.2230 shift). Fixed by gating notice stack mount on app shell hydration via MutationObserver useSyncExternalStore in src/components/pwa-lifecycle.tsx. Confirmed locally (0.2230 -> 0.000) and verified on CI Lighthouse budget run 32531103787 (CLS <= 0.016 budget). Closed together with #TYZK23 and #KFRC3H.", "created_at": "2026-08-27" }, + { + "request_id": "cf37b0db-a67f-4d7e-b90b-9e8ed36a5166", + "action": "cancel", + "summary": "Cancel request 6399ff26-1ea9-47ec-b23f-328c2bf10d6f: Superseded by PR 2406's own closure of KFRC3H, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands.", + "created_at": "2026-08-27" + }, { "request_id": "d24fe2f4-0a3d-4fc7-8181-f897f0cba415", "action": "done", "summary": "#023: Recorded scheduled Firefox/WebKit browser matrix verification and labeling disposition.", "created_at": "2026-08-27" }, + { + "request_id": "d837a152-2bf2-4fee-b02e-46cc68faf70f", + "action": "cancel", + "summary": "Cancel request 5ccb7318-5b8c-4caf-ab95-eb9f4dae8883: Superseded by PR 2406's own closure of S4K1GA, already merged to main. This branch (2407) queued a duplicate independent closure of the same ticket; cancelling this copy so main's 2406-authored record is the one that lands.", + "created_at": "2026-08-27" + }, { "request_id": "f5a27b65-309d-465c-9f5f-b87082206325", "action": "done", From 1df652b31365e9ce0e6edc197870da3b10d3d2b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:52:13 +0000 Subject: [PATCH 10/12] fix(db): regenerate drift manifest for documents metadata constraint Applies the drift-manifest.json produced by CI's own Migration replay job (run 33042193646), which correctly replays supabase/schema.sql through a scratch Postgres instance and confirms the new documents_metadata_object_check constraint. Local Docker access was unavailable to regenerate this directly. --- supabase/drift-manifest.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 07f3c176dc..3cad7f7004 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-08-25T16:44:53.791Z", + "generated_at": "2026-08-27T05:23:30.262Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "e53377d848c15693be107f9e1492fd55c86b89728c0504dfff97c3fbd89bb351", - "replay_seconds": 27, + "schema_sha256": "c0a031282d64c86ab2965210a5acc07dbcae6d4562ffc561b0cc7fad23dae2ea", + "replay_seconds": 11, "snapshot": { "views": [ { @@ -8609,6 +8609,11 @@ "name": "documents_import_batch_id_fkey", "table": "documents" }, + { + "def": "CHECK ((jsonb_typeof(metadata) = 'object'::text))", + "name": "documents_metadata_check", + "table": "documents" + }, { "def": "FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE SET NULL", "name": "documents_owner_id_fkey", From 50a1251520fbdbc36c21e5104833502f961655ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:08:45 +0000 Subject: [PATCH 11/12] fix(db): name the documents.metadata CHECK constraint in schema.sql schema.sql defined the constraint inline with no explicit name, so Postgres auto-named it documents_metadata_check. The migrations that actually create it in production name it documents_metadata_object_check. Since supabase/drift-manifest.json is generated by replaying schema.sql, the mismatch meant the manifest recorded the wrong constraint name for what production actually has. supabase/drift-manifest.json is regenerated in a follow-up commit once CI's Migration replay job produces the corrected artifact (local Docker pull of the pinned image is blocked by network policy in this session). --- supabase/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/schema.sql b/supabase/schema.sql index ab8bf2734b..0ebd70cac6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -77,7 +77,7 @@ create table if not exists public.documents ( image_count integer not null default 0, error_message text, metadata jsonb not null default '{}'::jsonb - check (jsonb_typeof(metadata) = 'object'), + constraint documents_metadata_object_check check (jsonb_typeof(metadata) = 'object'), search_tsv tsvector generated always as ( to_tsvector('english', coalesce(title, '') || ' ' || coalesce(file_name, '')) ) stored, From 5494d42034c67e7abc008bed17180f37d9b038c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 06:19:28 +0000 Subject: [PATCH 12/12] fix(db): regenerate drift manifest for corrected constraint name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects the documents_metadata_object_check name fix from 50a1251. Applies the manifest CI's own Migration replay job produced for this commit (run 33044847032) — local Docker pull of the pinned image is blocked by network policy in this session. --- supabase/drift-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 3cad7f7004..6a04f1f858 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-08-27T05:23:30.262Z", + "generated_at": "2026-08-27T06:12:59.010Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127@sha256:be60aee15997daca475b710b734bc6bfe52cd544dcd7e9fd2ff58210b6747d83", - "schema_sha256": "c0a031282d64c86ab2965210a5acc07dbcae6d4562ffc561b0cc7fad23dae2ea", + "schema_sha256": "3266b5376a36adf72f884ca63fde72c7867974219938d574bc62cf93e441f2eb", "replay_seconds": 11, "snapshot": { "views": [ @@ -8611,7 +8611,7 @@ }, { "def": "CHECK ((jsonb_typeof(metadata) = 'object'::text))", - "name": "documents_metadata_check", + "name": "documents_metadata_object_check", "table": "documents" }, {