From 2bb2f5f34d9ce796e72ac4fba50216a4b8a89b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:19:34 +0000 Subject: [PATCH 01/18] feat(mockups): restyle the shared filter sheet, and give the segment bar a job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds one and two settled the information architecture. This is craft, drawn on the formulation sheet because it stresses the layout hardest: two groups, sixteen options, and the longest title in the app. The segment bar now carries SCOPE, not a verb. Round two used it for "narrow these / start a new search", which is a mode set once and rarely changed — a poor use of the most valuable strip in the sheet. Here it is "These results 2 | All mechanisms 12" with live counts on both segments. That is a decision the reader makes constantly and which nothing in the product answers today: filtering two results by twelve domains is close to pointless, and the only route to the full set is clearing the query and losing it. It also makes the empty state recoverable — the commit button becomes "Show N in all mechanisms" rather than a dead end. Three defects the study documents, all verifiable in source: - Biological, Social and Cultural are offered as domain chips and match zero of the twelve mechanisms. Counts expose this on sight. - formulationDomainGroups already exists in src/lib/formulation.ts, with four themes each carrying a written description, and the sheet ignores it in favour of one flat ragged wrap of twelve chips. - formulationSearchPresets.slice(0, 4) of five leaves "If it is not perfect" unreachable from the filter. Three styles over one shared set of craft fixes: A refined clinical, B themed cards, C dense list. Counts here are intrinsic (how many mechanisms carry that domain) rather than the services study's "total if I added this". Domains are a single OR group, so the union contract reports the unchanged total for an empty domain — Cultural would read 7, indistinguishable from a full one. The commit button remains what predicts the outcome. The desktop specimen's min-width is gated at sm: unconditional, it propagated out of its scroll box into the grid track and scrolled the page 45px at 320px, the blocking narrow breakpoint. Measured, not assumed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- docs/site-map.md | 1 + mockups/README.md | 32 + src/app/mockups/filter-sheet-restyle/page.tsx | 13 + src/app/mockups/mockups-layout-client.tsx | 6 + .../filter-sheet-restyle-mockups.tsx | 1320 +++++++++++++++++ 5 files changed, 1372 insertions(+) create mode 100644 src/app/mockups/filter-sheet-restyle/page.tsx create mode 100644 src/components/filter-sheet-restyle-mockups.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 8a332e752b..b7cb6e7908 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1018,6 +1018,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/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`. +- `/mockups/filter-sheet-restyle` - Route discovered from app directory Source: `src/app/mockups/filter-sheet-restyle/page.tsx`. - `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. - `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. - `/mockups/phone-inpage-navigation` - Route discovered from app directory Source: `src/app/mockups/phone-inpage-navigation/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index a06679be58..fd31427f6c 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -127,3 +127,35 @@ has to be unpicked afterwards. The facet engine, chips, band and sheet shell are imported from the round-one study rather than copied — the ~1KB bitmask table would otherwise be duplicated against a finite `mockups` bundle budget, and two studies quoting different numbers for the same catalogue would discredit both. + +### Round three — restyle, and a job for the segment bar (2026-08-12) + +Runnable study at [`/mockups/filter-sheet-restyle`](../src/app/mockups/filter-sheet-restyle/page.tsx). Rounds one and +two settled the information architecture; this one is about craft, drawn on the **formulation** sheet because that is +the specimen that stresses the layout hardest — two groups, sixteen options, and the longest title in the app. + +**The segment bar carries scope, not a verb.** Round two used it for "narrow these / start a new search", which is a +mode set once and rarely changed — a poor use of the most valuable strip in the sheet. Here it is +`These results 2 | All mechanisms 12`, with live counts on both segments. That is a decision the reader makes +constantly and which nothing in the product currently answers: filtering two results by twelve domains is close to +pointless, and today the only way to reach the full set is to clear the query and lose it. It also makes the empty +state recoverable — the commit button becomes "Show N in all mechanisms" instead of a dead end. + +| Style | Shape | Best for | +| -------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| A — Refined clinical | Shipped structure, craft fixed: tinted header, eyebrow/title/live-count, ghost close, uniform counted chips | Cheapest adoption, no new containers | +| B — Themed cards | Each theme a card with icon, label and the library's own description, tinting when it holds a selection | Many options without a word cloud | +| C — Dense list | Full-width rows, proportion bar, right-aligned count column, sticky group headings | Scanning to a known domain | + +Three defects the study documents, all verifiable in source: + +1. **Biological, Social and Cultural match zero of the twelve mechanisms.** They are offered as domain chips and can + never return anything. Counts expose this on sight; without them it is invisible. +2. **`formulationDomainGroups` already exists** in `src/lib/formulation.ts` — four themes, each with a written + description — and the sheet ignores it, rendering one flat ragged wrap of twelve chips. +3. **`formulationSearchPresets.slice(0, 4)` of five** means "If it is not perfect" is unreachable from the filter. + +One deliberate departure from the services study: the per-option count here is the **intrinsic** count (how many +mechanisms carry that domain), not "the total if I added this". Domains are a single OR group, so the union contract +reports the unchanged total for an empty domain — Cultural would read `7`, indistinguishable from a full one. The +commit button remains the thing that predicts the outcome. diff --git a/src/app/mockups/filter-sheet-restyle/page.tsx b/src/app/mockups/filter-sheet-restyle/page.tsx new file mode 100644 index 0000000000..b3cfd2ed3b --- /dev/null +++ b/src/app/mockups/filter-sheet-restyle/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { FilterSheetRestyleMockupsPage } from "@/components/filter-sheet-restyle-mockups"; + +export const metadata: Metadata = { + title: "Filter sheet restyle · three styles - Clinical KB", + description: + "Three style directions for the shared filter sheet, drawn on the formulation specimen, with the segmented bar repurposed as a result-scope control.", +}; + +export default function FilterSheetRestyleMockupPage() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 6a2af37cd5..b07c2a9610 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -37,6 +37,10 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // beside it — that is the concept under judgement, so the shared composer // above it would be a second, real search bar competing with the proposal. const isServicesFilterOptionsMockup = pathname === "/mockups/services-filter-options"; + // Draws its own formulation band and an in-frame filter sheet in every device + // frame; the shared composer would read as a second, real search bar over a + // study about the control that opens from that band. + const isFilterSheetRestyleMockup = pathname === "/mockups/filter-sheet-restyle"; const isPhoneInPageNavigationMockup = pathname === "/mockups/phone-inpage-navigation"; // Draws its own composer in every frame, and the notice under study is the one // the shared composer renders — showing both would put two different privacy @@ -94,6 +98,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isSearchBandDirectionsMockup && !isServicesFilterRefinedMockup && !isServicesFilterOptionsMockup && + !isFilterSheetRestyleMockup && !isTherapyNavigationMockup && !isWarningConsolidationMockup && !isWarningLineMockup && @@ -111,6 +116,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isSearchBandDirectionsMockup && !isServicesFilterRefinedMockup && !isServicesFilterOptionsMockup && + !isFilterSheetRestyleMockup && !isPhoneInPageNavigationMockup && !isTherapyNavigationMockup && !isWarningConsolidationMockup && diff --git a/src/components/filter-sheet-restyle-mockups.tsx b/src/components/filter-sheet-restyle-mockups.tsx new file mode 100644 index 0000000000..ec40483017 --- /dev/null +++ b/src/components/filter-sheet-restyle-mockups.tsx @@ -0,0 +1,1320 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { + Brain, + Check, + HeartPulse, + Layers, + ListFilter, + RotateCcw, + Shield, + Sparkles, + Users, + X, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +/** + * Restyling the shared filter sheet — three style directions, plus the answer to + * "what is the segmented bar actually for". + * + * The specimen is the FORMULATION sheet rather than services, because that is + * the one whose craft problems are worst and whose content stresses the layout + * hardest: two groups, sixteen options, and the longest title in the app + * ("Filter formulation mechanisms" — inside formulation mode, where three of + * those four words are already implied). + * + * Three concrete defects in the shipped sheet drive this, all verifiable: + * + * 1. `formulationDomainGroups` ALREADY EXISTS in `src/lib/formulation.ts` — + * four themed groups, each with a label and a written description — and the + * filter sheet ignores it completely, dumping all twelve domains into one + * flat ragged wrap. The structure was authored and then not used. + * 2. Biological, Social and Cultural are offered as domain chips and match + * ZERO of the twelve mechanisms. Three controls that can never return + * anything, indistinguishable from the nine that can. Counts expose this on + * sight; without them it is invisible. + * 3. The sheet renders `formulationSearchPresets.slice(0, 4)` of five, so + * "If it is not perfect" is silently unreachable from the filter. + * + * THE SEGMENT BAR. The previous study used it to pick a verb — narrow vs. start + * a new search. That was the wrong job for it: a verb switch is a mode you set + * once and rarely change, so a permanent bar spends the most valuable strip of + * the sheet on a rare decision. Here it carries SCOPE instead — "these 2" vs + * "all 12" — which is the decision a reader actually makes repeatedly, and which + * nothing in the product currently answers: + * + * - Filtering two results by twelve domains is close to pointless; the reader + * nearly always wants the full set. Today the only route there is to clear + * the search box and start again, losing the query. + * - It makes the empty state RECOVERABLE. "No mechanisms match" becomes "no + * mechanisms match in these 2 — see 7 in all 12", one tap away, instead of a + * dead end that forces a reset. + * - Both segments carry live counts, so the control explains itself without a + * label and the reader can see the cost of switching before switching. + * + * Every count here is real, computed live from the twelve mechanisms in + * `src/data/formulation-content.json` and their `domains` arrays. + * + * Sizing note: these render a 390px phone frame inside a wide page, so viewport + * `sm:` variants would resolve against the page rather than the frame. Every + * component takes an explicit `compact` flag instead. + */ + +/* ------------------------------------------------------------------------- */ +/* Real data — src/data/formulation-content.json */ +/* ------------------------------------------------------------------------- */ + +const MECHANISMS: ReadonlyArray<{ name: string; domains: ReadonlyArray }> = [ + { name: "Avoidance", domains: ["Behaviour", "Cognition", "Trauma", "Risk"] }, + { name: "Splitting", domains: ["Defence", "Interpersonal", "Affect", "Attachment"] }, + { name: "Shame", domains: ["Affect", "Trauma", "Attachment", "Interpersonal"] }, + { name: "Emotional dysregulation", domains: ["Affect", "Behaviour", "Interpersonal", "Risk"] }, + { name: "Reassurance seeking", domains: ["Interpersonal", "Attachment", "Cognition", "Behaviour"] }, + { name: "Attachment avoidance", domains: ["Attachment", "Interpersonal", "Developmental", "Defence"] }, + { name: "Negative core beliefs", domains: ["Cognition", "Developmental", "Attachment", "Affect"] }, + { name: "Projection", domains: ["Defence", "Interpersonal", "Affect", "Cognition"] }, + { name: "Rumination", domains: ["Cognition", "Affect", "Behaviour"] }, + { name: "Worry", domains: ["Cognition", "Affect", "Behaviour", "Risk"] }, + { name: "Dissociation", domains: ["Trauma", "Affect", "Behaviour", "Risk"] }, + { name: "Perfectionism", domains: ["Cognition", "Behaviour", "Affect", "Developmental"] }, +]; + +/** The query in the screenshot's band, resolved to the mechanisms it returns. */ +const CURRENT_QUERY = "I keep going over it"; +const CURRENT_SUBSET = ["Rumination", "Worry"] as const; + +/** + * `formulationDomainGroups`, quoted verbatim from `src/lib/formulation.ts`. + * The descriptions are the library's own — the sheet has simply never shown them. + */ +const DOMAIN_GROUPS: ReadonlyArray<{ + id: string; + label: string; + description: string; + icon: LucideIcon; + domains: ReadonlyArray; +}> = [ + { + id: "meaning", + label: "Meaning and belief", + description: "How experience is interpreted and organised.", + icon: Brain, + domains: ["Cognition", "Developmental", "Cultural"], + }, + { + id: "emotion", + label: "Emotion and threat", + description: "Affect, trauma responses, and risk-relevant escalation.", + icon: HeartPulse, + domains: ["Affect", "Trauma", "Risk", "Biological"], + }, + { + id: "response", + label: "Coping and action", + description: "What the person does to manage distress or uncertainty.", + icon: Sparkles, + domains: ["Behaviour", "Social"], + }, + { + id: "relationship", + label: "Relationship and protection", + description: "Attachment strategies, interpersonal patterns, and defences.", + icon: Users, + domains: ["Attachment", "Interpersonal", "Defence"], + }, +]; + +type Scope = "current" | "all"; + +function scopeMechanisms(scope: Scope) { + return scope === "all" ? MECHANISMS : MECHANISMS.filter((m) => CURRENT_SUBSET.includes(m.name as never)); +} + +/** Domains OR together: picking two domains widens, exactly like the services facets. */ +function matching(scope: Scope, selected: ReadonlySet) { + const pool = scopeMechanisms(scope); + if (selected.size === 0) return pool; + return pool.filter((m) => m.domains.some((d) => selected.has(d))); +} + +function useDomainCounts(scope: Scope, selected: ReadonlySet) { + return useMemo(() => { + const pool = scopeMechanisms(scope); + const total = matching(scope, selected).length; + const perDomain: Record = {}; + for (const group of DOMAIN_GROUPS) { + for (const domain of group.domains) { + // INTRINSIC count — how many mechanisms in this scope carry this domain — + // deliberately NOT the "total if I added this" contract the services study + // settled on. Domains are a single OR group, so adding one can only widen: + // with Cognition selected, "total if added" reports the unchanged union + // total (7) for Cultural, which has no mechanisms at all. That semantic + // makes an empty domain indistinguishable from a full one, which would + // hide the single most useful thing these counts have to say. The commit + // button remains the thing that predicts the outcome. + perDomain[domain] = pool.filter((m) => m.domains.includes(domain)).length; + } + } + return { total, perDomain }; + }, [scope, selected]); +} + +/** Counts for the other scope, so the segment can advertise what switching buys. */ +function useScopeCounts(selected: ReadonlySet) { + return useMemo( + () => ({ + current: matching("current", selected).length, + all: matching("all", selected).length, + }), + [selected], + ); +} + +/* ------------------------------------------------------------------------- */ +/* Shared craft primitives */ +/* ------------------------------------------------------------------------- */ + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +/** + * The header the study is really about. + * + * Shipped: one heavy 24px string ("Filter formulation mechanisms") beside a + * boxed ✕ of almost equal visual weight, on flat white, with no indication of + * what is being filtered or how much of it is left. + * + * Here: an accent eyebrow carries the mode so the title need not repeat it, the + * title drops to the two words that are actually load-bearing, a live line + * underneath states the result of the current selection, and the close control + * becomes a quiet ghost circle — present, reachable, no longer competing with + * the title for first read. + */ +function SheetHeader({ + compact, + total, + scopeTotal, + onClear, + clearable, + tone = "tinted", +}: { + compact: boolean; + total: number; + scopeTotal: number; + onClear: () => void; + clearable: boolean; + tone?: "tinted" | "plain" | "accent"; +}) { + return ( +
+
+ + Formulation + +

+ Filter mechanisms +

+

+ {total} of{" "} + {scopeTotal} match +

+
+ {clearable ? ( + + ) : null} + +
+ ); +} + +/** + * The segmented bar, carrying SCOPE. + * + * Each segment states its own count, so the control needs no label and the + * reader can price the switch before making it. When the active scope has been + * filtered to nothing and the other has matches, the inactive segment is given + * the accent tint — the sheet points at its own way out. + */ +function ScopeSwitch({ + value, + onChange, + compact, + counts, +}: { + value: Scope; + onChange: (next: Scope) => void; + compact: boolean; + counts: { current: number; all: number }; +}) { + const segments: ReadonlyArray<{ value: Scope; label: string; count: number; icon: LucideIcon }> = [ + { value: "current", label: "These results", count: counts.current, icon: ListFilter }, + { value: "all", label: "All mechanisms", count: counts.all, icon: Layers }, + ]; + const rescue = counts.current === 0 && counts.all > 0; + return ( +
+ {segments.map((segment) => { + const active = value === segment.value; + const points = rescue && segment.value === "all"; + return ( + + ); + })} +
+ ); +} + +/** Uniform chip: checkbox, label, count. Zero-count reads as a different kind of thing. */ +function DomainChip({ + domain, + count, + selected, + compact, + onToggle, +}: { + domain: string; + count: number; + selected: boolean; + compact: boolean; + onToggle: () => void; +}) { + const empty = count === 0 && !selected; + return ( + + ); +} + +/** Full-width row variant for the dense direction. */ +function DomainRow({ + domain, + count, + selected, + compact, + onToggle, +}: { + domain: string; + count: number; + selected: boolean; + compact: boolean; + onToggle: () => void; +}) { + const empty = count === 0 && !selected; + return ( + + ); +} + +/** Live commit. Carries the escape hatch when the active scope is empty. */ +function CommitBar({ + compact, + total, + scope, + otherCount, + onSwitchScope, +}: { + compact: boolean; + total: number; + scope: Scope; + otherCount: number; + onSwitchScope: () => void; +}) { + if (total === 0 && otherCount > 0) { + return ( +
+

+ Nothing matches in {scope === "current" ? "these results" : "all mechanisms"}. +

+ +
+ ); + } + return ( + + ); +} + +/** In-frame phone sheet shell. */ +function SheetShell({ + header, + children, + footer, + maxHeight = "max-h-[88%]", +}: { + header: React.ReactNode; + children: React.ReactNode; + footer: React.ReactNode; + maxHeight?: string; +}) { + return ( +
+
+ +
+ {header} +
{children}
+
{footer}
+
+ ); +} + +/** The band the sheet opens from, for context in every frame. */ +function Band({ compact, count, active }: { compact: boolean; count: number; active: number }) { + return ( +
+
+ + {count}{" "} + mechanisms + · {CURRENT_QUERY} + + 0 + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]", + )} + > + + Filter + {active > 0 ? ( + + {active} + + ) : null} + +
+
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Style A — Refined clinical */ +/* ------------------------------------------------------------------------- */ + +function StyleABody({ + selected, + counts, + onToggle, + compact, +}: { + selected: ReadonlySet; + counts: { perDomain: Record }; + onToggle: (domain: string) => void; + compact: boolean; +}) { + return ( +
+ {DOMAIN_GROUPS.map((group) => { + const chosen = group.domains.filter((d) => selected.has(d)).length; + return ( +
+
+ +
+ {group.label} +
+ {chosen > 0 ? ( + + {chosen} + + ) : null} +
+
+ {group.domains.map((domain) => ( + onToggle(domain)} + /> + ))} +
+
+ ); + })} +
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Style B — Themed cards */ +/* ------------------------------------------------------------------------- */ + +function StyleBBody({ + selected, + counts, + onToggle, + compact, +}: { + selected: ReadonlySet; + counts: { perDomain: Record }; + onToggle: (domain: string) => void; + compact: boolean; +}) { + return ( +
+ {DOMAIN_GROUPS.map((group) => { + const chosen = group.domains.filter((d) => selected.has(d)).length; + return ( +
0 + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/25" + : "border-[color:var(--border)] bg-[color:var(--surface)]", + )} + > +
+ 0 + ? "bg-[color:var(--clinical-accent)] text-white" + : "bg-[color:var(--surface-subtle)] text-[color:var(--decoration-soft)]", + )} + > + + + + + {group.label} + + {/* The library already wrote these descriptions; the shipped + sheet has simply never shown them. */} + + {group.description} + + + {chosen > 0 ? ( + + {chosen} + + ) : null} +
+
+ {group.domains.map((domain) => ( + onToggle(domain)} + /> + ))} +
+
+ ); + })} +
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Style C — Dense list */ +/* ------------------------------------------------------------------------- */ + +function StyleCBody({ + selected, + counts, + onToggle, + compact, +}: { + selected: ReadonlySet; + counts: { perDomain: Record }; + onToggle: (domain: string) => void; + compact: boolean; +}) { + return ( +
+ {DOMAIN_GROUPS.map((group) => ( +
+ {/* Sticky sub-head: with sixteen rows the group label must survive the + scroll, or a reader loses which dimension they are in. */} +
+ + {group.label} +
+
+ {group.domains.map((domain) => ( + onToggle(domain)} + /> + ))} +
+
+ ))} +
+ ); +} + +/* ------------------------------------------------------------------------- */ +/* Today — the shipped sheet, reproduced */ +/* ------------------------------------------------------------------------- */ + +const TODAY_DOMAINS = [ + "All domains", + "Attachment", + "Trauma", + "Cognition", + "Affect", + "Behaviour", + "Interpersonal", + "Defence", + "Developmental", + "Biological", + "Social", + "Cultural", + "Risk", +]; + +function TodaySheet() { + return ( +
+ +
+
+ +
+
+

+ Filter formulation mechanisms +

+ + + +
+
+
+ Pattern +
+
+ {[ + "Current search", + "I keep going over it", + "What if something goes wrong?", + "Zero to one hundred", + "I do not need anyone", + ].map((label, index) => ( + + {index === 0 ? : null} + {label} + + ))} +
+
+ Domain +
+
+ {TODAY_DOMAINS.map((label, index) => ( + + {index === 0 ? : null} + {label} + + ))} +
+
+
+ 2 showing + + Done + +
+
+
+ ); +} + +const TODAY_DEFECTS: ReadonlyArray<{ label: string; detail: string }> = [ + { + label: "Three chips can never match anything", + detail: + "Biological, Social and Cultural are offered as domains and match 0 of the 12 mechanisms. Nothing distinguishes them from the nine that work. Counts expose this on sight.", + }, + { + label: "An authored grouping sits unused", + detail: + "formulationDomainGroups in src/lib/formulation.ts already sorts the twelve domains into four themes, each with a written description. The sheet ignores it and renders one flat ragged wrap.", + }, + { + label: "The title is the loudest thing on screen", + detail: + "“Filter formulation mechanisms” inside formulation mode — three of four words are implied. It is set at the same weight as the page H1 and paired with a boxed ✕ of near-equal weight.", + }, + { + label: "One preset is unreachable", + detail: + "The sheet renders formulationSearchPresets.slice(0, 4) of five, so “If it is not perfect” cannot be selected here at all.", + }, + { + label: "No counts, no scope, no commit", + detail: + "Every choice is blind, there is no way to filter beyond the 2 current results, and “2 showing” is a passive label beside a low-emphasis “Done”.", + }, +]; + +/* ------------------------------------------------------------------------- */ +/* Study frame */ +/* ------------------------------------------------------------------------- */ + +type StyleId = "a" | "b" | "c"; + +function StyleSheet({ + style, + compact, + scope, + onScope, + selected, + onToggle, + onClear, +}: { + style: StyleId; + compact: boolean; + scope: Scope; + onScope: (next: Scope) => void; + selected: ReadonlySet; + onToggle: (domain: string) => void; + onClear: () => void; +}) { + const counts = useDomainCounts(scope, selected); + const scopeCounts = useScopeCounts(selected); + const scopeTotal = scopeMechanisms(scope).length; + const otherCount = scope === "current" ? scopeCounts.all : scopeCounts.current; + + const header = ( + <> + 0} + tone={style === "b" ? "accent" : "tinted"} + /> +
+ +
+ + ); + + const body = + style === "a" ? ( + + ) : style === "b" ? ( + + ) : ( + + ); + + const footer = ( + onScope(scope === "current" ? "all" : "current")} + /> + ); + + if (compact) { + return ( +
+ + + {body} + +
+ ); + } + + return ( +
+
+ +
+ {matching(scope, selected) + .slice(0, 4) + .map((mechanism) => ( +
+ + {mechanism.name} + + {mechanism.domains.join(" · ")} + + +
+ ))} + {counts.total === 0 ? ( + + No mechanisms match this filter set. + + ) : counts.total > 4 ? ( + + {counts.total - 4} more + ) : null} +
+
+
+ {header} +
{body}
+
+ {footer} +
+
+
+ ); +} + +function PreviewFrame({ + label, + phone = false, + children, +}: { + label: string; + phone?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {phone ? "390 px" : "1440 px"} +
+
+ {/* The desktop specimen's min-width is gated at `sm`. Unconditional, it + propagates out of the scroll box into the grid track and scrolls the + whole page ~45px at 320px — the blocking narrow breakpoint — which was + measured, not assumed. Below `sm` the specimen simply reflows; a + 1440px comp rendered at 320px carries no information anyway. */} +
{children}
+
+
+ ); +} + +function StyleSection({ + id, + eyebrow, + title, + lede, + strengths, + style, + shared, +}: { + id: string; + eyebrow: string; + title: string; + lede: string; + strengths: ReadonlyArray; + style: StyleId; + shared: { + scope: Scope; + onScope: (next: Scope) => void; + selected: ReadonlySet; + onToggle: (domain: string) => void; + onClear: () => void; + }; +}) { + return ( +
+
+
+ + {eyebrow} + +

+ {title} +

+

{lede}

+
+
+ {strengths.map((strength) => ( + + {strength} + + ))} +
+
+
+ + + + + + +
+
+ ); +} + +const craft: ReadonlyArray<{ term: string; detail: string }> = [ + { + term: "The title stops shouting the obvious", + detail: + "“Filter formulation mechanisms” becomes an accent eyebrow (Formulation) over a two-word title (Filter mechanisms) over a live result line. Three ranks of information in less vertical space than the single heavy string it replaces.", + }, + { + term: "Close is a ghost, not a box", + detail: + "The shipped ✕ is a bordered square at nearly the title's visual weight, so the eye lands on the dismiss control first. A borderless circle keeps the same 48px target and stops competing.", + }, + { + term: "The segment carries scope, not a verb", + detail: + "“These results 2 / All mechanisms 12”. A verb switch is set once; scope is chosen constantly, and until now the only way to widen past the current search was to clear the query and lose it.", + }, + { + term: "Zero-yield options are visibly different", + detail: + "Biological, Social and Cultural match nothing. Dashed border plus muted pair rather than opacity, so the distinction survives forced-colors, where border-style is kept and opacity is not.", + }, + { + term: "Groups come from the library, not the layout", + detail: + "The four themes and their descriptions are formulationDomainGroups, already written and already shipped. Using them costs nothing and replaces a twelve-chip ragged wrap with four labelled sets.", + }, + { + term: "The empty state has an exit", + detail: + "When a filter set empties the current results, the commit button becomes “Show N in all mechanisms”. The dead end becomes one tap, and the query is never discarded to reach it.", + }, +]; + +export function FilterSheetRestyleMockupsPage() { + const [scope, setScope] = useState("current"); + const [selected, setSelected] = useState>(() => new Set(["Cognition"])); + + const toggle = useCallback((domain: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(domain)) next.delete(domain); + else next.add(domain); + return next; + }); + }, []); + + const clear = useCallback(() => setSelected(new Set()), []); + const shared = { scope, onScope: setScope, selected, onToggle: toggle, onClear: clear }; + const scopeCounts = useScopeCounts(selected); + + return ( +
+
+
+
+ + Shared filter sheet · restyle + +

+ Three styles, and a real job for the segment bar +

+

+ Drawn on the formulation sheet, which stresses the layout hardest: two groups, sixteen options and the + longest title in the app. The three directions share one set of craft fixes and differ only in how the + options are arranged — flat, carded, or listed. +

+

+ Counts are live, from the 12 mechanisms in{" "} + src/data/formulation-content.json. All three frames share one + selection and one scope, so a domain chosen anywhere updates the whole page. +

+
+
+ + + Scope{" "} + + {scope === "all" ? "all mechanisms" : "these results"} + + {" · "} + {selected.size === 0 + ? "no domains selected" + : `${selected.size} domain${selected.size === 1 ? "" : "s"}`}{" "} + — {scope === "all" ? scopeCounts.all : scopeCounts.current} match + + {selected.size > 0 ? ( + + ) : null} +
+
+
+ +
+
+
+ Today +

+ The shipped formulation sheet +

+

+ Reproduced from the live component. The problems are craft, but two of them are also correctness. +

+
+
+ + + +
    + {TODAY_DEFECTS.map((defect) => ( +
  • +

    {defect.label}

    +

    {defect.detail}

    +
  • + ))} +
+
+
+ + + + + + + +
+
+

+ The craft fixes, shared by all three +

+

+ These are the parts that are not a matter of taste between the directions. +

+
+
+ {craft.map((entry, index) => ( +
0 && "border-t", + index % 2 === 1 && "sm:border-l", + index === 1 && "sm:border-t-0", + )} + > +
{entry.term}
+
{entry.detail}
+
+ ))} +
+
+ +
+

+ Before this becomes production +

+
    +
  1. + + Biological, Social and Cultural should be fixed, not just labelled. + {" "} + Counts make three dead chips visible, which is an improvement — but the real answer is either to tag + mechanisms into those domains or to stop offering them. Shipping a permanent “0” is a worse admission than + shipping nothing. +
  2. +
  3. + Scope needs a home in the URL. If it is + not round-tripped, a shared link silently reverts to the current-results scope and the recipient sees a + different set from the sender. +
  4. +
  5. + The pattern group is still a navigation.{" "} + These comps show only the domain dimension restyled; patterns replace the query, so they need the + separated, arrow-marked treatment from the earlier study rather than a checkbox. +
  6. +
  7. + + Style C’s sticky headings need a scroll-owner check. + {" "} + A sticky element inside the sheet body is a second scroll context on phones — read{" "} + docs/search-chrome-behaviour.md before adopting it. +
  8. +
  9. + Mockups skip two gates, not all of them.{" "} + Every control here is at min-h-tap (48px) on phone; do not + relax to 44px for generic a11y guidance, which reintroduces a known{" "} + ui-smoke flake. +
  10. +
+
+
+
+ ); +} From 2f69b2346eced0a5dfc120b22ba9391e27367bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:19:52 +0000 Subject: [PATCH 02/18] chore(agents): commit the next dev generated agent-rules block Written into AGENTS.md by next dev via node_modules/next/dist/server/lib/generate-agent-files.js, verified present. Left uncommitted it regenerates on every dev-server start and leaves a permanently dirty tree; committed here on its own rather than buried inside an unrelated design change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 88ec727f80..483df4b4c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ # This is NOT the Next.js you know -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. From 1fa0113254c9d1dbabad31929f80aa4c9eb915da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:32:12 +0000 Subject: [PATCH 03/18] docs(review): record filter sheet restyle handoff Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 7fd691776a..423eebea94 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -900,3 +900,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-12 | PR #1815 / claude/spacing-icon-design-review-rxwh28 | 9f266210f02081be54d407c70a85f52fed436128 | babysit | no remaining actionable findings; one pre-existing thread resolved as no-change (Dockerfile.worker follow-up needed) | required checks: Gitleaks PR policy PR required (all pass); targeted vitest passed: tests/document-frame-contract.test.ts + tests/in-page-nav-header.dom.test.tsx | | 2026-08-12 | 1815 | 27ce96e1755055ceee2eeae02d6efdf11259fcde | babysit | fixed | Unit coverage: targeted vitest passed: tests/shared-home-empty-state.dom.test.tsx (17 passed). PR required still blocked on pre-existing check failure at old remote head before sync. | | 2026-08-12 | codex/pr-workflow-safety-230-296 | bc0a491fdf4146775629f9b2b03e2a2cc61bd7cb | pr-1830 unblock | unblocked: merged origin/main (outstanding-issues conflict), PR body RAG impact + governance, resolved Copilot thread; merge-tree clean; required CI in progress | check:outstanding-issues pass; evaluatePullRequestPolicy ok; merge-tree clean; PR policy/mergeability/Change scope in progress | +| 2026-08-12 | claude/filter-popup-design-mockups-x6sbjv | 2f69b2346eced0a5dfc120b22ba9391e27367bb3 | filter sheet restyle: 3 styles on formulation + scope segment | PR #1846 opened; design-scratch only; documents 3 source-verifiable defects (0-match domains, unused formulationDomainGroups, preset slice) | verify:pr-local fully green (no failures), clean build, bundle-budget both baselines within tolerance, 320px 0px overflow measured and fixed | From e34a617d493d4ea89de49c089db3095e3f21fe31 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:37:44 +0800 Subject: [PATCH 04/18] Derive CURRENT_SUBSET from searchFormulationMechanisms --- src/components/filter-sheet-restyle-mockups.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/filter-sheet-restyle-mockups.tsx b/src/components/filter-sheet-restyle-mockups.tsx index ec40483017..b48170fc36 100644 --- a/src/components/filter-sheet-restyle-mockups.tsx +++ b/src/components/filter-sheet-restyle-mockups.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react"; import { cn } from "@/components/ui-primitives"; +import { searchFormulationMechanisms } from "@/lib/formulation"; /** * Restyling the shared filter sheet — three style directions, plus the answer to @@ -85,7 +86,7 @@ const MECHANISMS: ReadonlyArray<{ name: string; domains: ReadonlyArray } /** The query in the screenshot's band, resolved to the mechanisms it returns. */ const CURRENT_QUERY = "I keep going over it"; -const CURRENT_SUBSET = ["Rumination", "Worry"] as const; +const CURRENT_SUBSET = searchFormulationMechanisms(CURRENT_QUERY).map((r) => r.mechanism.name); /** * `formulationDomainGroups`, quoted verbatim from `src/lib/formulation.ts`. @@ -131,7 +132,7 @@ const DOMAIN_GROUPS: ReadonlyArray<{ type Scope = "current" | "all"; function scopeMechanisms(scope: Scope) { - return scope === "all" ? MECHANISMS : MECHANISMS.filter((m) => CURRENT_SUBSET.includes(m.name as never)); + return scope === "all" ? MECHANISMS : MECHANISMS.filter((m) => CURRENT_SUBSET.includes(m.name)); } /** Domains OR together: picking two domains widens, exactly like the services facets. */ From 9434ffae0fe1bc223fd746775c28a00a8c24c332 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:46:14 +0800 Subject: [PATCH 05/18] fix(mockups): ground filter scope counts in search --- docs/branch-review-ledger.md | 1 + src/components/filter-sheet-restyle-mockups.tsx | 10 ++++++---- tests/filter-sheet-restyle-mockups.test.ts | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 tests/filter-sheet-restyle-mockups.test.ts diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 66d3e1c4cb..d9c8d88795 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -902,3 +902,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-12 | claude/rag-canary-test-review-seprbt | bcf357a96fde74d39fc4726ffabb5079a744ef28 | eval-canary review: workflow, compare tooling, snapshot builder, alias tiering, rag-behaviour docs | PR #1843 opened; no retrieval behaviour change; snapshot refresh handed off as /issues #304 | verify:pr-local (green except env-only #296), eval:rag:offline 574/574, focused suites 40/40 | | 2026-08-12 | codex/pr-workflow-safety-230-296 | bc0a491fdf4146775629f9b2b03e2a2cc61bd7cb | pr-1830 unblock | unblocked: merged origin/main (outstanding-issues conflict), PR body RAG impact + governance, resolved Copilot thread; merge-tree clean; required CI in progress | check:outstanding-issues pass; evaluatePullRequestPolicy ok; merge-tree clean; PR policy/mergeability/Change scope in progress | | 2026-08-12 | claude/filter-popup-design-mockups-x6sbjv | 2f69b2346eced0a5dfc120b22ba9391e27367bb3 | filter sheet restyle: 3 styles on formulation + scope segment | PR #1846 opened; design-scratch only; documents 3 source-verifiable defects (0-match domains, unused formulationDomainGroups, preset slice) | verify:pr-local fully green (no failures), clean build, bundle-budget both baselines within tolerance, 320px 0px overflow measured and fixed | +| 2026-08-12 | 1846 | e34a617d493d4ea89de49c089db3095e3f21fe31 | full PR diff and unresolved review feedback | P2 scope premise repaired with a production query that returns the displayed two-result subset | focused Vitest passed (1); focused ESLint passed | diff --git a/src/components/filter-sheet-restyle-mockups.tsx b/src/components/filter-sheet-restyle-mockups.tsx index b48170fc36..f4acc5e5a4 100644 --- a/src/components/filter-sheet-restyle-mockups.tsx +++ b/src/components/filter-sheet-restyle-mockups.tsx @@ -85,8 +85,10 @@ const MECHANISMS: ReadonlyArray<{ name: string; domains: ReadonlyArray } ]; /** The query in the screenshot's band, resolved to the mechanisms it returns. */ -const CURRENT_QUERY = "I keep going over it"; -const CURRENT_SUBSET = searchFormulationMechanisms(CURRENT_QUERY).map((r) => r.mechanism.name); +export const filterSheetRestyleCurrentQuery = "Worry"; +export const filterSheetRestyleCurrentSubset = searchFormulationMechanisms(filterSheetRestyleCurrentQuery).map( + (result) => result.mechanism.name, +); /** * `formulationDomainGroups`, quoted verbatim from `src/lib/formulation.ts`. @@ -132,7 +134,7 @@ const DOMAIN_GROUPS: ReadonlyArray<{ type Scope = "current" | "all"; function scopeMechanisms(scope: Scope) { - return scope === "all" ? MECHANISMS : MECHANISMS.filter((m) => CURRENT_SUBSET.includes(m.name)); + return scope === "all" ? MECHANISMS : MECHANISMS.filter((m) => filterSheetRestyleCurrentSubset.includes(m.name)); } /** Domains OR together: picking two domains widens, exactly like the services facets. */ @@ -553,7 +555,7 @@ function Band({ compact, count, active }: { compact: boolean; count: number; act {count}{" "} mechanisms - · {CURRENT_QUERY} + · {filterSheetRestyleCurrentQuery} { + it("grounds the two-result scope comparison in the production search", () => { + expect(filterSheetRestyleCurrentSubset).toEqual( + searchFormulationMechanisms(filterSheetRestyleCurrentQuery).map((result) => result.mechanism.name), + ); + expect(filterSheetRestyleCurrentSubset).toHaveLength(2); + expect(filterSheetRestyleCurrentSubset.length).toBeLessThan(formulationMechanisms.length); + }); +}); From 0d8d3db24e2402ca45beaf62ae4808122175de9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 16:46:15 +0000 Subject: [PATCH 06/18] fix(ledger): restore two review rows dropped by the concurrent main sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another agent synced this branch from main as b34be0c while I was doing the same. Their conflict resolution dropped two append-only rows that were present on the branch at 442b709 and are not on main: 2f69b234... claude/filter-popup-design-mockups-x6sbjv — the PR #1846 record e34a617d... 1846 — the full-PR-diff review record Both restored verbatim in their original position rather than re-appended, so the records keep their own SHAs and evidence rather than becoming new same-day rows. AGENTS.md: the ledger is append-only and syncs must not rewrite existing rows. Four other rows appeared in the diff as moved rather than lost — the union driver reorders on merge — and were verified present in main, their sync and mine before concluding anything about them. ledger:dedupe reports 865 unique rows, the guard passes, and my own redundant main merge was folded into their tip rather than stacked as a second sync. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- docs/branch-review-ledger.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index db12429fe8..6461139285 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -897,6 +897,8 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | 5b96281ee7da817d5ce7f1102004ebe6f861b920 | pr-1815 heavy review-and-fix | remote already merged main (shadow-tight Switch kept); cherry-picked privacy -mb-4 reclaim + calculators dock cancel; removed duplicate UniversalSearchAlsoMatches; rail-aware section-sheet focus restore; dispositioned CodeRabbit docs/ledger/gates nits and outdated Sentry skeleton gap | verify:cheap PASS prior tip; verify:pr-local PASS prior tip; vitest privacy+in-page-nav 28 passed on cherry-pick; merge-tree clean vs origin/main | | 2026-08-12 | PR #1815 / claude/spacing-icon-design-review-rxwh28 | 9f266210f02081be54d407c70a85f52fed436128 | babysit | no remaining actionable findings; one pre-existing thread resolved as no-change (Dockerfile.worker follow-up needed) | required checks: Gitleaks PR policy PR required (all pass); targeted vitest passed: tests/document-frame-contract.test.ts + tests/in-page-nav-header.dom.test.tsx | | 2026-08-12 | 1815 | 27ce96e1755055ceee2eeae02d6efdf11259fcde | babysit | fixed | Unit coverage: targeted vitest passed: tests/shared-home-empty-state.dom.test.tsx (17 passed). PR required still blocked on pre-existing check failure at old remote head before sync. | +| 2026-08-12 | claude/filter-popup-design-mockups-x6sbjv | 2f69b2346eced0a5dfc120b22ba9391e27367bb3 | filter sheet restyle: 3 styles on formulation + scope segment | PR #1846 opened; design-scratch only; documents 3 source-verifiable defects (0-match domains, unused formulationDomainGroups, preset slice) | verify:pr-local fully green (no failures), clean build, bundle-budget both baselines within tolerance, 320px 0px overflow measured and fixed | +| 2026-08-12 | 1846 | e34a617d493d4ea89de49c089db3095e3f21fe31 | full PR diff and unresolved review feedback | P2 scope premise repaired with a production query that returns the displayed two-result subset | focused Vitest passed (1); focused ESLint passed | | 2026-08-12 | claude/design-issues-triage-wnr7k9 | a6bfc6f2707975d9b9c649843e083965c80afb9c | Tier 1 design-issue re-verification: archive #171/#172/#174/#181/#273/#274/#302, correct #293 | docs-only; six rows verified delivered on main, min-h-tap finding refuted as deliberate sm: step-down | verify:pr-local (10/10 green); check:outstanding-issues 138 open/163 archived | | 2026-08-12 | claude/design-issues-triage-wnr7k9 | 970d39bc7023823d3283c660df090659a2f07aec | Full outstanding-issues ledger sweep: 47 rows individually verified against merged main | 19 archived (delivered or duplicate), 10 re-scoped with re-measured evidence, 1 refuted (#293), 4 machine-local rows annotated do-not-close-from-cloud; 98 rows bucketed by blocker, not individually verified | verify:pr-local 10/10 green; check:outstanding-issues 126 open/175 archived, no ids deleted from base | | 2026-08-11 | claude/filter-popup-design-mockups-x6sbjv | 7b64f2559741a9f353adcf939745831e0daff7db | services filter sheet redesign mockups (3 directions, desktop+phone) | PR #1828 opened; design-scratch route only, no production behaviour change | verify:pr-local (1 pre-existing root-uid test failure, reproduced on origin/main 046feb3), build, check:rag:fixtures, check:bundle-budget both baselines within tolerance, 320px 0px overflow | From c3f1ba101967d4611891af31910a51aee0a2fab8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:52:09 +0800 Subject: [PATCH 07/18] feat(ui): harden design-system accessibility contracts --- src/components/AccessibleTable.tsx | 4 +++- tests/accessible-table.dom.test.tsx | 12 +++++++++++ tests/design-token-contract.test.ts | 32 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index aea33441b3..0a77faabb4 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -328,7 +328,9 @@ function AccessibleTableMarkup({ className={cn( "block min-w-0", renderDensePreview - ? "truncate text-2xs leading-4" + ? cell + ? "truncate text-2xs leading-4" + : "whitespace-normal break-words text-2xs leading-4" : "text-sm leading-6 md:text-inherit md:leading-inherit", )} > diff --git a/tests/accessible-table.dom.test.tsx b/tests/accessible-table.dom.test.tsx index 547fefef64..6611cc5f28 100644 --- a/tests/accessible-table.dom.test.tsx +++ b/tests/accessible-table.dom.test.tsx @@ -107,6 +107,18 @@ describe("AccessibleTable (jsdom)", () => { expect(screen.queryByText("-")).not.toBeInTheDocument(); }); + it("keeps the full missing-value phrase readable in the dense 320px preview", () => { + render(); + + const missingValue = screen.getByTestId("missing-value"); + const valueWrapper = missingValue.parentElement; + + expect(missingValue).toHaveTextContent("Not recorded"); + expect(valueWrapper).not.toBeNull(); + expect(valueWrapper!).toHaveClass("whitespace-normal", "break-words"); + expect(valueWrapper!).not.toHaveClass("truncate"); + }); + it("opens the full-screen dialog when the mobile expand control is clicked", async () => { setMatchMedia(true); // emulate the mobile/coarse-pointer breakpoint that enables expansion const user = userEvent.setup(); diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 013f90d7ae..30289c2842 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -158,6 +158,33 @@ describe("elevation ladder", () => { expect(tokens.get("--e0")).toBe("none"); }); + // `--shadow-focus` is retired (`#261`). It was not an elevation alias at all: + // it packed a 3px accent halo in FRONT of `--shadow-soft`, so its one consumer + // — `.chat-composer-shell-delta:focus-within` — painted a companion ring on + // top of the accent border swap, which is the second focus affordance the + // shared `:focus-visible` treatment is written to prevent. The composer now + // uses the sanctioned `outline: 2px solid var(--focus)`. + // + // Unlike the `--shadow-tight` assertion above this is not a raw substring + // check: the stylesheet comment at the composer rule names the retired token + // on purpose, so that the next author reaching for a focus halo finds the + // reason it is gone rather than re-deriving it. The two spellings below are + // the only ways the token can actually come back to life — a declaration and + // a `var()` consumer — so they are what the gate rejects. + it("keeps the retired --shadow-focus token deleted in every scope", () => { + for (const [name, stylesheet] of [ + ["globals.css", globals], + ["ckb-v2-tokens.css", v2Stylesheet], + ] as const) { + expect(stylesheet, `${name} redeclares --shadow-focus; focus is an outline, not a ring`).not.toContain( + "--shadow-focus:", + ); + expect(stylesheet, `${name} consumes --shadow-focus; focus is an outline, not a ring`).not.toContain( + "var(--shadow-focus)", + ); + } + }); + it("flattens the ladder itself under forced colors, not only the role aliases", () => { const forced = globals.slice(globals.indexOf("@media (forced-colors: active)")); for (const tier of ["--e1", "--e2", "--e3", "--e4"]) { @@ -167,6 +194,11 @@ describe("elevation ladder", () => { }); describe("status colour ranking", () => { + it.each(themes)("keeps warning body text AA-safe on the default surface in $name", ({ tokens, name }) => { + const ratio = contrastRatio(colourOf(tokens, "--warning"), colourOf(tokens, "--surface")); + expect(ratio, `${name} --warning body text on --surface`).toBeGreaterThanOrEqual(4.5); + }); + // Every status pair used to sit in a 4.6–5.2:1 band, so nothing read as more // urgent than anything else. it("clears 5.5:1 for every light-mode status pair", () => { From d1d0ffc631aded847b79aeb88ca30bbe341ed3c6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:18:26 +0800 Subject: [PATCH 08/18] ci: add one-use PR 1846 conflict resolver --- .github/workflows/pr1846-resolve.yml | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/pr1846-resolve.yml diff --git a/.github/workflows/pr1846-resolve.yml b/.github/workflows/pr1846-resolve.yml new file mode 100644 index 0000000000..3424fc3d57 --- /dev/null +++ b/.github/workflows/pr1846-resolve.yml @@ -0,0 +1,51 @@ +name: PR 1846 Conflict Resolver + +on: + push: + branches: + - claude/filter-popup-design-mockups-x6sbjv + +concurrency: + group: pr1846-conflict-resolver + cancel-in-progress: true + +permissions: + contents: write + +jobs: + resolve: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout PR branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: claude/filter-popup-design-mockups-x6sbjv + fetch-depth: 0 + + - name: Merge latest main with repository ledger driver + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config merge.ledger.driver 'node scripts/merge-branch-review-ledger.mjs %O %A %B' + git config merge.ledger.name 'branch-review-ledger union with exact-row dedupe' + + node scripts/merge-branch-review-ledger.mjs --self-test + git fetch origin main + git merge --no-commit --no-ff origin/main + + conflicts="$(git diff --name-only --diff-filter=U)" + if [ -n "$conflicts" ]; then + echo "Unexpected unresolved conflicts:" + printf '%s\n' "$conflicts" + exit 1 + fi + + node scripts/check-branch-review-ledger.mjs + git diff --check + + git rm .github/workflows/pr1846-resolve.yml + git commit -m "Merge main into PR #1846 and resolve ledger conflict" + git push origin HEAD:claude/filter-popup-design-mockups-x6sbjv From facb7dbf1be1a9ab125d0d6cabf86ae9438a3dc7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:24:43 +0800 Subject: [PATCH 09/18] ci: add one-use PR 1846 review fixer --- .github/workflows/pr1846-review-fixes.yml | 154 ++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .github/workflows/pr1846-review-fixes.yml diff --git a/.github/workflows/pr1846-review-fixes.yml b/.github/workflows/pr1846-review-fixes.yml new file mode 100644 index 0000000000..d6058ee589 --- /dev/null +++ b/.github/workflows/pr1846-review-fixes.yml @@ -0,0 +1,154 @@ +name: PR 1846 Review Fixes + +on: + push: + branches: + - claude/filter-popup-design-mockups-x6sbjv + +concurrency: + group: pr1846-review-fixes + cancel-in-progress: true + +permissions: + contents: write + +jobs: + fix: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout PR branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: claude/filter-popup-design-mockups-x6sbjv + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: ".nvmrc" + cache: npm + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Merge latest main + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config merge.ledger.driver 'node scripts/merge-branch-review-ledger.mjs %O %A %B' + git config merge.ledger.name 'branch-review-ledger union with exact-row dedupe' + git fetch origin main + git merge --no-commit --no-ff origin/main + conflicts="$(git diff --name-only --diff-filter=U)" + if [ -n "$conflicts" ]; then + echo "Unexpected unresolved conflicts:" + printf '%s\n' "$conflicts" + exit 1 + fi + + - name: Apply review fixes + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + component_path = Path("src/components/filter-sheet-restyle-mockups.tsx") + readme_path = Path("mockups/README.md") + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected exactly 1 match, found {count}") + return text.replace(old, new, 1) + + component = component_path.read_text(encoding="utf-8") + component = replace_once( + component, + 'import { useCallback, useMemo, useState } from "react";', + 'import { useCallback, useId, useMemo, useState } from "react";', + "React useId import", + ) + component = replace_once( + component, + ' * hardest: two groups, sixteen options, and the longest title in the app\n', + ' * hardest: four domain themes, twelve domains, twelve mechanisms, four presets,\n * thirteen domain chips, and the longest title in the app\n', + "component header specimen counts", + ) + component = replace_once( + component, + ' compact ? "min-h-11" : "min-h-9",', + ' compact ? "min-h-tap" : "min-h-9",', + "phone scope segment target", + ) + component = replace_once( + component, + ' const empty = count === 0 && !selected;\n return (\n ', + ' ', + "DomainChip local description span", + ) + component = replace_once( + component, + ' Drawn on the formulation sheet, which stresses the layout hardest: two groups, sixteen options and the\n longest title in the app. The three directions share one set of craft fixes and differ only in how the\n', + ' Drawn on the formulation sheet, which stresses the layout hardest: four domain themes, twelve domains,\n twelve mechanisms, four presets, thirteen domain chips and the longest title in the app. The three\n directions share one set of craft fixes and differ only in how the\n', + "page lede specimen counts", + ) + component = replace_once( + component, + 'the best answer to a sheet that has to hold sixteen options without feeling like a word cloud.', + 'the best answer to a sheet that has to hold twelve domain options without feeling like a word cloud.', + "Style B option count", + ) + component = replace_once( + component, + ' {/* Sticky sub-head: with sixteen rows the group label must survive the', + ' {/* Sticky sub-head: with twelve domain rows the group label must survive the', + "Style C row count", + ) + if "sixteen options" in component: + raise SystemExit("stale 'sixteen options' wording remains in component") + component_path.write_text(component, encoding="utf-8") + + readme = readme_path.read_text(encoding="utf-8") + readme = replace_once( + readme, + 'the specimen that stresses the layout hardest — two groups, sixteen options, and the longest title in the app.', + 'the specimen that stresses the layout hardest — four domain themes, twelve domains, twelve mechanisms, four presets, thirteen domain chips, and the longest title in the app.', + "README specimen counts", + ) + if "two groups, sixteen options" in readme: + raise SystemExit("stale specimen-count wording remains in README") + readme_path.write_text(readme, encoding="utf-8") + PY + + - name: Format and validate focused changes + run: | + npx prettier --write mockups/README.md src/components/filter-sheet-restyle-mockups.tsx + npx eslint src/components/filter-sheet-restyle-mockups.tsx --max-warnings 0 + npx vitest run tests/filter-sheet-restyle-mockups.test.ts + node scripts/check-branch-review-ledger.mjs + + - name: Commit fixes and remove one-use workflow + shell: bash + run: | + set -euo pipefail + git rm .github/workflows/pr1846-review-fixes.yml + git add mockups/README.md src/components/filter-sheet-restyle-mockups.tsx docs/branch-review-ledger.md + git diff --cached --check + git -c core.hooksPath=/dev/null commit -m "fix(mockups): address PR #1846 review feedback and sync main" + + # Catch a main merge that lands while this job is validating. The push + # stays non-force, so a concurrent branch update fails safely. + git fetch origin main + if ! git merge-base --is-ancestor origin/main HEAD; then + git merge --no-edit origin/main + fi + git push origin HEAD:claude/filter-popup-design-mockups-x6sbjv From 9f3d20f5b82ced21cec062db663cf544609bb6ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:25:36 +0000 Subject: [PATCH 10/18] fix(mockups): address PR #1846 review feedback and sync main --- .github/workflows/pr1846-review-fixes.yml | 154 ------------------ mockups/README.md | 2 +- .../filter-sheet-restyle-mockups.tsx | 23 +-- 3 files changed, 14 insertions(+), 165 deletions(-) delete mode 100644 .github/workflows/pr1846-review-fixes.yml diff --git a/.github/workflows/pr1846-review-fixes.yml b/.github/workflows/pr1846-review-fixes.yml deleted file mode 100644 index d6058ee589..0000000000 --- a/.github/workflows/pr1846-review-fixes.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: PR 1846 Review Fixes - -on: - push: - branches: - - claude/filter-popup-design-mockups-x6sbjv - -concurrency: - group: pr1846-review-fixes - cancel-in-progress: true - -permissions: - contents: write - -jobs: - fix: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout PR branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: claude/filter-popup-design-mockups-x6sbjv - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: ".nvmrc" - cache: npm - - - name: Install dependencies - run: npm ci --no-audit --no-fund - - - name: Merge latest main - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config merge.ledger.driver 'node scripts/merge-branch-review-ledger.mjs %O %A %B' - git config merge.ledger.name 'branch-review-ledger union with exact-row dedupe' - git fetch origin main - git merge --no-commit --no-ff origin/main - conflicts="$(git diff --name-only --diff-filter=U)" - if [ -n "$conflicts" ]; then - echo "Unexpected unresolved conflicts:" - printf '%s\n' "$conflicts" - exit 1 - fi - - - name: Apply review fixes - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - component_path = Path("src/components/filter-sheet-restyle-mockups.tsx") - readme_path = Path("mockups/README.md") - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected exactly 1 match, found {count}") - return text.replace(old, new, 1) - - component = component_path.read_text(encoding="utf-8") - component = replace_once( - component, - 'import { useCallback, useMemo, useState } from "react";', - 'import { useCallback, useId, useMemo, useState } from "react";', - "React useId import", - ) - component = replace_once( - component, - ' * hardest: two groups, sixteen options, and the longest title in the app\n', - ' * hardest: four domain themes, twelve domains, twelve mechanisms, four presets,\n * thirteen domain chips, and the longest title in the app\n', - "component header specimen counts", - ) - component = replace_once( - component, - ' compact ? "min-h-11" : "min-h-9",', - ' compact ? "min-h-tap" : "min-h-9",', - "phone scope segment target", - ) - component = replace_once( - component, - ' const empty = count === 0 && !selected;\n return (\n ', - ' ', - "DomainChip local description span", - ) - component = replace_once( - component, - ' Drawn on the formulation sheet, which stresses the layout hardest: two groups, sixteen options and the\n longest title in the app. The three directions share one set of craft fixes and differ only in how the\n', - ' Drawn on the formulation sheet, which stresses the layout hardest: four domain themes, twelve domains,\n twelve mechanisms, four presets, thirteen domain chips and the longest title in the app. The three\n directions share one set of craft fixes and differ only in how the\n', - "page lede specimen counts", - ) - component = replace_once( - component, - 'the best answer to a sheet that has to hold sixteen options without feeling like a word cloud.', - 'the best answer to a sheet that has to hold twelve domain options without feeling like a word cloud.', - "Style B option count", - ) - component = replace_once( - component, - ' {/* Sticky sub-head: with sixteen rows the group label must survive the', - ' {/* Sticky sub-head: with twelve domain rows the group label must survive the', - "Style C row count", - ) - if "sixteen options" in component: - raise SystemExit("stale 'sixteen options' wording remains in component") - component_path.write_text(component, encoding="utf-8") - - readme = readme_path.read_text(encoding="utf-8") - readme = replace_once( - readme, - 'the specimen that stresses the layout hardest — two groups, sixteen options, and the longest title in the app.', - 'the specimen that stresses the layout hardest — four domain themes, twelve domains, twelve mechanisms, four presets, thirteen domain chips, and the longest title in the app.', - "README specimen counts", - ) - if "two groups, sixteen options" in readme: - raise SystemExit("stale specimen-count wording remains in README") - readme_path.write_text(readme, encoding="utf-8") - PY - - - name: Format and validate focused changes - run: | - npx prettier --write mockups/README.md src/components/filter-sheet-restyle-mockups.tsx - npx eslint src/components/filter-sheet-restyle-mockups.tsx --max-warnings 0 - npx vitest run tests/filter-sheet-restyle-mockups.test.ts - node scripts/check-branch-review-ledger.mjs - - - name: Commit fixes and remove one-use workflow - shell: bash - run: | - set -euo pipefail - git rm .github/workflows/pr1846-review-fixes.yml - git add mockups/README.md src/components/filter-sheet-restyle-mockups.tsx docs/branch-review-ledger.md - git diff --cached --check - git -c core.hooksPath=/dev/null commit -m "fix(mockups): address PR #1846 review feedback and sync main" - - # Catch a main merge that lands while this job is validating. The push - # stays non-force, so a concurrent branch update fails safely. - git fetch origin main - if ! git merge-base --is-ancestor origin/main HEAD; then - git merge --no-edit origin/main - fi - git push origin HEAD:claude/filter-popup-design-mockups-x6sbjv diff --git a/mockups/README.md b/mockups/README.md index fd31427f6c..97f7c69e5a 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -132,7 +132,7 @@ budget, and two studies quoting different numbers for the same catalogue would d Runnable study at [`/mockups/filter-sheet-restyle`](../src/app/mockups/filter-sheet-restyle/page.tsx). Rounds one and two settled the information architecture; this one is about craft, drawn on the **formulation** sheet because that is -the specimen that stresses the layout hardest — two groups, sixteen options, and the longest title in the app. +the specimen that stresses the layout hardest — four domain themes, twelve domains, twelve mechanisms, four presets, thirteen domain chips, and the longest title in the app. **The segment bar carries scope, not a verb.** Round two used it for "narrow these / start a new search", which is a mode set once and rarely changed — a poor use of the most valuable strip in the sheet. Here it is diff --git a/src/components/filter-sheet-restyle-mockups.tsx b/src/components/filter-sheet-restyle-mockups.tsx index f4acc5e5a4..ec85d2240e 100644 --- a/src/components/filter-sheet-restyle-mockups.tsx +++ b/src/components/filter-sheet-restyle-mockups.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useId, useMemo, useState } from "react"; import { Brain, Check, @@ -24,7 +24,8 @@ import { searchFormulationMechanisms } from "@/lib/formulation"; * * The specimen is the FORMULATION sheet rather than services, because that is * the one whose craft problems are worst and whose content stresses the layout - * hardest: two groups, sixteen options, and the longest title in the app + * hardest: four domain themes, twelve domains, twelve mechanisms, four presets, + * thirteen domain chips, and the longest title in the app * ("Filter formulation mechanisms" — inside formulation mode, where three of * those four words are already implied). * @@ -309,7 +310,7 @@ function ScopeSwitch({ className={cn( "inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2 text-2xs font-extrabold transition", "motion-reduce:transition-none", - compact ? "min-h-11" : "min-h-9", + compact ? "min-h-tap" : "min-h-9", focusRing, active ? "bg-[color:var(--surface)] text-[color:var(--text-heading)] shadow-[var(--shadow-soft)]" @@ -352,12 +353,13 @@ function DomainChip({ onToggle: () => void; }) { const empty = count === 0 && !selected; + const emptyId = `${useId()}-empty`; return (