From a154913da7ff5ef932655eb358a3b10eb251a2c7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 11 Oct 2026 04:40:21 +0000
Subject: [PATCH 01/10] Give both modes one look: Clinical band, grouped lists,
calmer controls
Owner-approved design pass from the 11 Oct 2026 review (decision C8).
- Clinical top bars and tab bars share the mode's soft tint as one band;
the tabs stay in the collapse track and the bottom search box is untouched.
- Work band and top bar lose the dot texture; the band drawing is fainter.
- Clinical page headings share one eyebrow, title and description style.
- Dictionary, Sources, Therapy pathways, Formulation, Specifiers, Forms and
Differentials lists group items as divided rows in one hairline card, with
no side bars and no cards inside cards.
- Priority labels are neutral tags; red stays for urgent. Forms shows its
count once. Black Clinical buttons take the area colour.
- Work chip rows stay on one line and scroll with an edge fade; Bookings'
Book looks like a button; Documents' empty state gets a line drawing.
- Work polish: one way back on five pages, aligned profile statuses, On Call
type sizes and grouped People, First week and Leave wallet tidy-ups,
example wording in CPD, and the Leave and requests gap.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01T6yVRM8TyEHCooxU8ke9nA
---
docs/design-system/DECISIONS.md | 32 ++++
.../(search-app)/documents/search/page.tsx | 69 +++++++-
src/app/mode-band.css | 36 ++++
src/app/work-mode.css | 82 ++++++---
.../admin/contract/contract-end-page.tsx | 3 +-
.../admin/leave/leave-wallet-page.tsx | 23 +--
src/components/admin/renewals/kind-chips.tsx | 8 +-
.../master-search-header.tsx | 9 +
.../cme/applications/applications-page.tsx | 12 +-
src/components/cme/cme-entry-form.tsx | 2 +-
src/components/cme/cme-new-entry-route.tsx | 3 +-
src/components/cme/cme-quick-log.tsx | 2 +-
.../dictionary/dictionary-catalogue-pages.tsx | 85 +++++++--
.../dictionary/dictionary-result-row.tsx | 107 +++++------
.../differential-stream-workspace.tsx | 42 +++--
.../factsheets/factsheets-search-page.tsx | 7 +-
src/components/first-nations/fn-button.tsx | 29 ++-
.../forms/forms-search-results-page.tsx | 77 ++++----
.../formulation/formulation-builder-page.tsx | 44 ++---
src/components/mode-band/mode-band.tsx | 7 +-
src/components/mode-kit/work.tsx | 7 +-
.../mode-picker/mode-picker-row.tsx | 3 +-
.../my-day/my-day-calendar-page.tsx | 4 +-
src/components/on-call/call/call-page.tsx | 56 +++---
.../on-call/first-week/first-week-page.tsx | 68 +++----
src/components/on-call/kit/grouped-list.tsx | 23 ++-
src/components/on-call/now/right-now.tsx | 7 +-
.../on-call/now/signed-out-example.tsx | 10 +-
.../open-shifts/open-shifts-browse-page.tsx | 38 +---
.../roster/requests/roster-requests-page.tsx | 10 +-
.../roster/staffing/roster-staffing-entry.tsx | 19 +-
.../sources/sources-browse-client.tsx | 16 +-
.../sources/sources-catalogue-client.tsx | 19 +-
.../specifiers/specifier-builder-page.tsx | 24 ++-
src/components/specifiers/specifier-ui.tsx | 4 +-
src/components/therapy-compass/controls.ts | 14 +-
.../therapy-compass/pathway-picker-sheet.tsx | 8 +-
.../therapy-compass/pathway-step-stack.tsx | 40 +++--
.../screens/pathways-screen.tsx | 2 +-
.../tools/tools-search-results-page.tsx | 9 +-
src/components/ui/catalogue-toolbar.tsx | 166 +++++++++---------
src/components/ui/page-header.tsx | 33 ++--
.../work-frame/work-frame-header.tsx | 2 +-
.../work-profile/work-profile-page.tsx | 17 +-
.../work-screens/admin/bookings-page.tsx | 10 +-
tests/admin-contract-page.dom.test.tsx | 5 +-
tests/admin-leave-wallet-page.dom.test.tsx | 3 +-
tests/admin-renewals-page.dom.test.tsx | 9 +-
tests/cme-applications-page.dom.test.tsx | 4 +-
tests/first-nations-design.dom.test.tsx | 11 +-
...forms-search-register-listing.dom.test.tsx | 9 +-
tests/on-call-first-week-page.dom.test.tsx | 3 +-
tests/open-shifts-browse-sample.dom.test.tsx | 5 +-
53 files changed, 863 insertions(+), 474 deletions(-)
diff --git a/docs/design-system/DECISIONS.md b/docs/design-system/DECISIONS.md
index 6ba277fcc4..1ac35419fa 100644
--- a/docs/design-system/DECISIONS.md
+++ b/docs/design-system/DECISIONS.md
@@ -213,6 +213,38 @@ convergence.
---
+## C8 · One look for both modes (owner design review, 11 October 2026)
+
+**Chose.** After a review of every Work and Clinical screen at 320 px, 390 px, desktop and
+dark mode, the owner approved these seven rules for both modes:
+
+1. **One look for both modes.** Clinical keeps its own components and behaviour, including
+ the search box at the bottom of the screen and how it works, but shares Work's look: round
+ header buttons on a tinted top bar, the tab bar in the same tint, hairline cards, the same
+ controls and illustrations, in Clinical blue. No search bar is added at the top.
+2. **Calmer bands.** The band and top bar carry no dot texture, and the band's line drawing
+ is fainter (0.08 light, 0.11 dark, from 0.11 and 0.14).
+3. **Primary buttons take the area colour.** Never black or ink-filled buttons, in either mode.
+4. **Filter chips stay on one line.** They scroll sideways with an edge fade and never wrap to
+ leave one chip alone.
+5. **Red means overdue, lapsed or urgent only,** in both modes. Priority and category labels
+ ("High", "Medium") use neutral tags.
+6. **One way back.** If the header shows Back, the page adds no extra "‹ My Day" style link
+ under the band.
+7. **One card per group,** in both modes: items sit as divided rows in one hairline card, with
+ no cards inside cards and no coloured side bars.
+
+**Rejected.** A top search bar or a title band in Clinical mode. The owner asked that the
+Clinical search box keep its bottom position and behaviour, and that Clinical keep its own
+components; a title band would also have moved the tab bar out of the top bar's collapse
+track and changed how it hides on scroll.
+
+**Why.** The two modes read as two different apps (different header, list, card and button
+styles). Sharing one visual language while keeping each mode's own controls balances them
+without retraining the reader.
+
+---
+
## Q&A record — clinical inputs (31 July 2026)
| # | Question | Answer | Design consequence |
diff --git a/src/app/(search-app)/documents/search/page.tsx b/src/app/(search-app)/documents/search/page.tsx
index 62d3dc8559..efb6432d06 100644
--- a/src/app/(search-app)/documents/search/page.tsx
+++ b/src/app/(search-app)/documents/search/page.tsx
@@ -1,16 +1,78 @@
import type { Metadata } from "next";
import Link from "next/link";
import { buttonFaceClass } from "@/components/ui/button-face";
+import { cn } from "@/components/ui-primitives";
export const metadata: Metadata = {
title: "Document Search - PsychSift",
description: "Search indexed clinical documents and review matching evidence.",
};
+// Primary buttons wear the area colour, never the shared near-black command
+// fill (design pass, October 2026). Re-pointing the command tokens on the
+// button itself keeps Button's hover and pressed states and leaves every other
+// control on the page alone.
+const clinicalPrimaryAccent =
+ "[--command:var(--clinical-accent)] [--command-hover:var(--clinical-accent-hover)] [--command-active:var(--clinical-accent-active)] [--command-contrast:var(--clinical-accent-contrast)]";
+
+// The empty screen's line drawing: two stacked documents and a magnifier, in
+// the area colour. It repeats what the heading says, so it is hidden from
+// screen readers. Fills use tokens through classes so dark mode follows.
+function DocumentsSearchIllustration() {
+ return (
+
+ );
+}
+
export default function DocumentsSearchRoute() {
return (
-
+
+
Indexed sources
Search clinical documents
@@ -19,7 +81,10 @@ export default function DocumentsSearchRoute() {
matching page and passage.
-
+
Open Documents search
diff --git a/src/app/mode-band.css b/src/app/mode-band.css
index c1bf9b9757..fb93d6c548 100644
--- a/src/app/mode-band.css
+++ b/src/app/mode-band.css
@@ -433,3 +433,39 @@ html[data-mode-band] [data-testid="chrome-safe-area-top"] {
:is(div, header):has(> div:only-child > [data-under-band]:only-child) {
display: contents;
}
+
+/* The clinical band (owner design review, 11 Oct 2026). Every Clinical page
+ but the shared home tints its top bar with the mode's soft colour, and a
+ mode with its own pages (Dictionary, Differentials, Sources and the rest)
+ tints its tab bar to match, with the band's hairline under it, so the two
+ read as one band the way a work page's top bar and band do. The tab bar
+ stays in the top bar's collapse track, so it still hides and reveals with
+ the top bar exactly as before. The round header buttons are already white
+ circles, which the tint now shows. The header marks the page in its server
+ HTML (`data-clinical-band`), so the tint is there from the first paint. A
+ page under a mode band (Medicines, Psychiatry) keeps the band's own colour. */
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) {
+ --clinical-band-bg: var(--mode-identity-soft);
+ --clinical-band-line: var(--mode-identity-border);
+}
+
+html.dark:not([data-mode-band]):has(.universal-header[data-clinical-band]) {
+ --clinical-band-bg: color-mix(in srgb, var(--mode-identity) 11%, var(--background));
+ --clinical-band-line: color-mix(in srgb, var(--mode-identity) 26%, var(--background));
+}
+
+html:not([data-mode-band]) .universal-header[data-clinical-band],
+html:not([data-mode-band]) [data-testid="chrome-safe-area-top"][data-clinical-band],
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) #phone-header-collapse-addon-slot .mode-nav-rail {
+ background: var(--clinical-band-bg);
+}
+
+html:not([data-mode-band]):has(.universal-header[data-clinical-band]) #phone-header-collapse-addon-slot .mode-nav-rail {
+ border-bottom-color: var(--clinical-band-line);
+}
+
+/* A tinted top bar with no tab bar under it ends on the band's hairline. */
+html:not([data-mode-band])
+ .universal-header[data-clinical-band]:not(:has(~ #phone-header-collapse-addon-slot .mode-nav-rail)) {
+ box-shadow: inset 0 -1px 0 var(--clinical-band-line);
+}
diff --git a/src/app/work-mode.css b/src/app/work-mode.css
index 6fbad8c776..4b41df967c 100644
--- a/src/app/work-mode.css
+++ b/src/app/work-mode.css
@@ -193,21 +193,6 @@ html[data-work-frame] main {
}
}
-/* The dot texture, lined up with the top bar's so the two read as one. */
-.work-band::before,
-html[data-work-frame] .universal-header[data-work-frame]::after,
-.universal-header[data-work-band]::after {
- content: "";
- position: absolute;
- inset: 0;
- z-index: var(--work-z-under);
- pointer-events: none;
- background-image: radial-gradient(var(--mode-identity-border) 0.7px, transparent 0.8px);
- background-size: 12px 12px;
- background-position: 0 var(--work-band-offset, 0);
- opacity: 0.55;
-}
-
/* The faint line drawing, cropped by its own box at the band's corner. */
.work-band__mark {
position: absolute;
@@ -219,12 +204,13 @@ html[data-work-frame] .universal-header[data-work-frame]::after,
background: var(--mode-identity);
mask: var(--work-band-mark, none) left top / 12.25rem 12.25rem no-repeat;
-webkit-mask: var(--work-band-mark, none) left top / 12.25rem 12.25rem no-repeat;
- opacity: 0.11;
+ /* Fainter since the 11 Oct design review: colour says where you are, the drawing only adds a hint. */
+ opacity: 0.08;
pointer-events: none;
}
.dark .work-band__mark {
- opacity: 0.14;
+ opacity: 0.11;
}
/* Pinned tabs (owner request 8 Oct 2026). Once the title has scrolled under
@@ -1670,18 +1656,70 @@ a.work-hero:focus-visible {
}
/* Chips: filters and choices. A 30px shape inside a 48px tap. */
+/* Chip rows stay on one line and scroll sideways (design pass, October 2026):
+ wrapping left one chip alone on a second line. Where scroll-driven
+ animations exist, a soft fade appears only at an edge with more chips past
+ it, and only while the row actually overflows. Elsewhere (older iPhone
+ Safari) there is no fade at all, so no chip is ever dimmed. The tab swipe
+ already ignores a row that scrolls sideways. */
.work-chips {
display: flex;
- flex-wrap: wrap;
- gap: 0 calc(var(--spacing) * 1.5);
-}
-
-.work-chips[data-scroll] {
flex-wrap: nowrap;
+ gap: 0 calc(var(--spacing) * 1.5);
overflow-x: auto;
+ overscroll-behavior-x: contain;
scrollbar-width: none;
}
+.work-chips::-webkit-scrollbar {
+ display: none;
+}
+
+@property --work-chips-fade-start {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+
+@property --work-chips-fade-end {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+
+@keyframes work-chips-fade {
+ from {
+ --work-chips-fade-start: 0px;
+ --work-chips-fade-end: 1.75rem;
+ }
+ 1% {
+ --work-chips-fade-start: 1.75rem;
+ }
+ 99% {
+ --work-chips-fade-end: 1.75rem;
+ }
+ to {
+ --work-chips-fade-start: 1.75rem;
+ --work-chips-fade-end: 0px;
+ }
+}
+
+@supports (animation-timeline: scroll()) {
+ .work-chips {
+ /* A row that does not overflow has an inactive timeline, so both fades
+ stay at 0px and nothing is masked. */
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black var(--work-chips-fade-start),
+ black calc(100% - var(--work-chips-fade-end)),
+ transparent
+ );
+ animation: work-chips-fade linear both;
+ animation-timeline: scroll(self inline);
+ }
+}
+
.work-chip {
position: relative;
display: inline-flex;
diff --git a/src/components/admin/contract/contract-end-page.tsx b/src/components/admin/contract/contract-end-page.tsx
index 3d6c33157d..a1e9108ef4 100644
--- a/src/components/admin/contract/contract-end-page.tsx
+++ b/src/components/admin/contract/contract-end-page.tsx
@@ -19,7 +19,6 @@ import {
createEntry,
deleteEntry,
errorWords,
- JuniorBackLink,
JuniorFootNote,
JuniorNotice,
JuniorSectionLabel,
@@ -392,7 +391,7 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
return (
<>
-
+ {/* One way back: the band's Today tab is Admin Today, so the page adds no second link to it. */}
Contract end
diff --git a/src/components/admin/leave/leave-wallet-page.tsx b/src/components/admin/leave/leave-wallet-page.tsx
index 7e3349833b..53d5be3870 100644
--- a/src/components/admin/leave/leave-wallet-page.tsx
+++ b/src/components/admin/leave/leave-wallet-page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
BookOpen,
+ ChevronRight,
Copy,
EyeOff,
ExternalLink,
@@ -25,7 +26,6 @@ import { ContractEndEntryLink } from "@/components/admin/contract/contract-entry
import { LeaveEntitlementList } from "@/components/admin/leave/leave-entitlement-list";
import {
copyLabel,
- JuniorBackLink,
JuniorFootNote,
JuniorSectionLabel,
JuniorUndoBar,
@@ -588,7 +588,7 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {
return (
-
+ {/* One way back: the band's Today tab is Admin Today, so the page adds no second link to it. */}
Leave wallet
@@ -664,14 +664,17 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {
Your wallet
{/*
- The mockup's wallet: each card's top edge tucks 8 px under the card before it, a deliberate stacked
- deck. Each card is at least 64 px tall, so at least 56 px of every card stays uncovered and tappable,
- and the overlap only ever covers the bottom padding, never a word. A focused card lifts above the
- next so its focus ring shows in full.
+ One grouped card with a hairline between leave types, the same list shape as the rest of Work mode,
+ rather than a stacked deck of separate cards. Each row is a full-width button at least 64 px tall.
*/}
-
+
{visible.map((type, index) => (
-
0 && "-mt-2")}>
+
0 && "border-t border-[color:var(--border)]")}>
))}
diff --git a/src/components/admin/renewals/kind-chips.tsx b/src/components/admin/renewals/kind-chips.tsx
index bcdb216d8e..0e899a2257 100644
--- a/src/components/admin/renewals/kind-chips.tsx
+++ b/src/components/admin/renewals/kind-chips.tsx
@@ -20,9 +20,9 @@ export function checklistKindLabel(kind: ChecklistKindFilter): string {
/**
* The kind filter row (Josh's locked mockup): "All, Registration, Checks,
* Health, Training, Job" as the work kit's chips. `aria-pressed`, not a radio
- * group: a chip narrows the list rather than replacing it. The chips wrap onto
- * a second line on a phone rather than scrolling sideways, so none is ever
- * hidden off the edge (and a sideways drag here never fights the tab swipe).
+ * group: a chip narrows the list rather than replacing it. Like every chip
+ * row, they stay on one line and scroll sideways, with an edge fade showing
+ * there is more; the tab swipe ignores a row that scrolls sideways.
*/
export function ChecklistKindChips({
active,
@@ -34,7 +34,7 @@ export function ChecklistKindChips({
readonly testId?: string;
}) {
return (
-
+
{CHECKLIST_KIND_FILTERS.map((kind) => (
(null);
@@ -2540,6 +2547,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) {
// before the band has published it on (work-mode.css).
data-work-band={routeWorkFramed ? "" : undefined}
data-mode-identity={routeWorkFrame?.area.identity}
+ data-clinical-band={routeClinicalBand ? "" : undefined}
className={cn(
// No backdrop-filter on the header itself: it would form a backdrop
// root and starve the .edge-glass-header-backdrop scrim (the single
@@ -3126,6 +3134,7 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) {
// Tinted with the top bar from the first paint on a framed work page.
data-work-band={routeWorkFramed ? "" : undefined}
data-mode-identity={routeWorkFrame?.area.identity}
+ data-clinical-band={routeClinicalBand ? "" : undefined}
className={cn(
// Visible phone chrome owns the OS inset. Hidden phone chrome must
// release it so the scroll surface reaches the physical viewport
diff --git a/src/components/cme/applications/applications-page.tsx b/src/components/cme/applications/applications-page.tsx
index 7e1c78831f..783833cb87 100644
--- a/src/components/cme/applications/applications-page.tsx
+++ b/src/components/cme/applications/applications-page.tsx
@@ -8,7 +8,6 @@ import { focusRing } from "@/components/card-recipes";
import {
ActionDock,
CpdFeaturePage,
- type CpdFeatureBack,
flatCard,
flatRow,
IconCircle,
@@ -49,8 +48,7 @@ import {
import { perthCalendarDate } from "@/lib/cme/cpd-year";
import { useApplicationsStore } from "@/lib/cme/device-record";
-/** The page's parent, where its link lives. */
-const APPLICATIONS_BACK: CpdFeatureBack = { href: "/cme/summary", label: "CPD summary" };
+// No page-level back link: the band's Summary tab is this page's parent, so a second way back would repeat it.
type DateSheetState = { open: false } | { open: true; stage: ApplicationStageId | null };
type RefereeSheetState = { open: false } | { open: true; id: string | null };
@@ -76,12 +74,7 @@ export function ApplicationsPage({ demoMode, now }: { readonly demoMode: boolean
if (!state) {
return (
-
+
Loading your applications
@@ -193,7 +186,6 @@ export function ApplicationsPage({ demoMode, now }: { readonly demoMode: boolean
return (
{(field) => (
{demoMode ? (
- Demo mode lets you try the form. Saving is available only in your signed-in private record.
+ This is an example, so you can try the form. Saving is available only in your signed-in private record.
) : null}
{choices.length > 0 ? (
diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx
index ed9d8616db..be3746cdcb 100644
--- a/src/components/dictionary/dictionary-catalogue-pages.tsx
+++ b/src/components/dictionary/dictionary-catalogue-pages.tsx
@@ -48,6 +48,7 @@ import {
parseDictionaryCatalogueParams,
type DictionaryCatalogueScope,
type DictionaryCatalogueSort,
+ type DictionarySearchHit,
} from "@/lib/dictionary";
import {
dictionaryEntryKinds,
@@ -115,6 +116,39 @@ function catalogueNoun(scope: DictionaryCatalogueScope, count: number) {
return count === 1 ? "term" : "terms";
}
+/**
+ * One hairline card per group of rows. Rows divide themselves with a hairline;
+ * the card is the only box, at every width, so a phone sees the same grouped
+ * list a desktop does rather than an edge-to-edge strip.
+ */
+const dictionaryGroupCard =
+ "min-w-0 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)]";
+
+function dictionaryHitKey(hit: DictionarySearchHit) {
+ return hit.type === "entry"
+ ? `entry-${hit.entry.slug}`
+ : hit.type === "topic"
+ ? `topic-${hit.topic.slug}`
+ : `abbr-${hit.abbreviation}`;
+}
+
+/**
+ * Splits an alphabetical list into letter groups ("A · 6 terms"). A ranked
+ * search result is one group with no heading: its order is relevance, and
+ * cutting it at letter boundaries would scatter the best match.
+ */
+function groupHitsByLetter(hits: readonly DictionarySearchHit[], alphabetical: boolean) {
+ if (!alphabetical) return [{ letter: null, hits: [...hits] }];
+ const groups: { letter: string | null; hits: DictionarySearchHit[] }[] = [];
+ for (const hit of hits) {
+ const letter = dictionaryBrowseLetter(hit);
+ const last = groups.at(-1);
+ if (last && last.letter === letter) last.hits.push(hit);
+ else groups.push({ letter, hits: [hit] });
+ }
+ return groups;
+}
+
/**
* The clinical dictionary catalogue — one destination for the whole list.
*
@@ -144,6 +178,10 @@ export function DictionaryCataloguePage() {
const [letterOpen, setLetterOpen] = useState(false);
const hits = useMemo(() => dictionaryCatalogue(params), [params]);
+ const hitGroups = useMemo(
+ () => groupHitsByLetter(hits, params.sort === "az" || params.sort === "za"),
+ [hits, params.sort],
+ );
// Both counts come from the same predicate as the list, so a segment's number
// is what pressing it actually returns (docs/filter-contract.md). While a
// query runs they are the matches for that query, not the catalogue totals.
@@ -438,28 +476,36 @@ export function DictionaryCataloguePage() {
+ ))}
) : (
// The empty state wears the same card as the result list. Bare on
// the page background it read as a rendering failure rather than an
// answer, and the advice named a filter even when none was applied.
-
diff --git a/src/components/dictionary/dictionary-result-row.tsx b/src/components/dictionary/dictionary-result-row.tsx
index a67941e11e..13cc1c537b 100644
--- a/src/components/dictionary/dictionary-result-row.tsx
+++ b/src/components/dictionary/dictionary-result-row.tsx
@@ -1,50 +1,59 @@
import Link from "next/link";
-import { ArrowRight, Check, GitCompareArrows, Layers3 } from "lucide-react";
+import { ArrowRight, BookOpenText, CaseUpper, Check, GitCompareArrows, Layers3 } from "lucide-react";
import { stretchedRowLinkClass } from "@/components/card-recipes";
import { cn } from "@/components/ui-primitives";
import { dictionaryCompareHref, dictionaryKindLabel, type DictionarySearchHit } from "@/lib/dictionary";
-const tone = {
- entry: {
- spine: "bg-[color:var(--clinical-accent)]",
- badge:
- "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
- },
- abbreviation: {
- spine: "bg-[color:var(--tone-purple)]",
- badge:
- "border-[color:var(--tone-purple-border)] bg-[color:var(--tone-purple-soft)] text-[color:var(--tone-purple)]",
- },
- topic: {
- spine: "bg-[color:var(--info)]",
- badge: "border-[color:var(--info-border)] bg-[color:var(--info-bg)] text-[color:var(--info)]",
- },
-} as const;
+// One group, one card: the rows sit inside a hairline card the page draws, so
+// a row carries no card of its own and no coloured side bar. Colour is spent
+// once per row, on the category tag, and only in the current area's tint, so
+// it marks what kind of thing the row is without competing with the title.
+const categoryTag =
+ "rounded-md border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-1.5 py-0.5 text-3xs font-extrabold text-[color:var(--clinical-accent)]";
+const neutralTag =
+ "rounded-md border border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-1.5 py-0.5 text-3xs font-bold text-[color:var(--text-muted)]";
-export function DictionaryResultRow({ hit, compareWith }: { hit: DictionarySearchHit; compareWith?: string }) {
+// Rows are divided by a hairline, never boxed. 48 px is the floor for a row
+// that is itself a link target. `relative` is added at each row, where the
+// stretched-link contract test can see it.
+const rowShell =
+ "flex min-h-12 min-w-0 items-start gap-3 border-b border-[color:var(--border)] px-3 py-3.5 last:border-b-0 sm:px-4 sm:py-4";
+
+// The small area-tinted mark at the left of each row. Hidden at
+// phone widths, where its 44 px is better spent on the definition and on
+// keeping "Source linked" and "Add to compare" on one line.
+const rowIcon =
+ "mt-0.5 hidden size-8 shrink-0 place-items-center rounded-lg bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] sm:grid";
+
+type TitleTag = "h2" | "h3";
+
+export function DictionaryResultRow({
+ hit,
+ compareWith,
+ titleAs: Title = "h2",
+}: {
+ hit: DictionarySearchHit;
+ compareWith?: string;
+ /** `h3` when the row sits under a group heading (a letter or a kind). */
+ titleAs?: TitleTag;
+}) {
if (hit.type === "abbreviation") {
const primary = hit.senses[0];
return (
-
-
-
+ // One hairline card per group with divided rows (no cards inside cards).
+ // From the two-column width each row draws its own bottom and inner
+ // right hairline; the -mb-px tucks the last row's bottom line under the
+ // card's clipped edge so it is never doubled.
+
;
+ return (
+
+
+
+ );
+}
diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx
index 46de81bcb0..e7264980b0 100644
--- a/src/components/forms/forms-search-results-page.tsx
+++ b/src/components/forms/forms-search-results-page.tsx
@@ -30,7 +30,6 @@ import {
eyebrowText,
ignoreUnavailableActivation,
pageContainer,
- panelSubtle,
searchFocusRing,
searchPageCanvas,
searchResultsSection,
@@ -63,19 +62,12 @@ function resultCode(match: FormSearchMatch, index: number) {
return formCatalogDetails(match.service)?.form ?? String(index + 1);
}
-function tagToneClass(label: string) {
- const normalized = label.toLowerCase();
- if (normalized.includes("crisis") || normalized.includes("risk") || normalized.includes("safety")) {
- return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
- }
- if (normalized.includes("transport") || normalized.includes("transfer") || normalized.includes("handover")) {
- return "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]";
- }
- if (normalized.includes("legal") || normalized.includes("detention") || normalized.includes("capacity")) {
- return "bg-[color:var(--info-soft)] text-[color:var(--info)]";
- }
- return "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]";
-}
+// Category chips are labels, not alerts, so they all wear the same quiet
+// neutral tag. Red is kept for overdue, lapsed or urgent states only (design
+// pass, October 2026); a red "crisis" chip on a register row competed with
+// real warnings elsewhere on screen.
+const categoryTagClass =
+ "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]";
/**
* With no query the page is the register itself, not a search. Every form is
@@ -142,12 +134,15 @@ function compactMatchReason(match: FormSearchMatch, query: string) {
return "Content match in the forms catalogue";
}
-// Risk is the only badge a phone result card carries, so it must never out-rank
-// itself: high is solid danger, medium is a bordered wash, low stays neutral.
+// Risk is the only badge a phone result card carries. It is a recorded
+// priority, not an alert, so it is a neutral tag whose weight carries the
+// level: high is body-coloured on a faint wash with a hairline, medium is muted
+// text, low is muted and lighter. Red on most of the register trained the eye
+// to ignore it; red now means overdue, lapsed or urgent only.
const riskBadgeToneClass: Record = {
- high: "bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]",
- medium: "border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]",
- low: "border border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
+ high: "border border-[color:var(--border-strong)] bg-[color:var(--surface-subtle)] text-[color:var(--text)]",
+ medium: "text-[color:var(--text-muted)]",
+ low: "font-semibold text-[color:var(--text-muted)]",
};
// The catalogue generates a `purpose` for every form, but most are boilerplate
@@ -246,9 +241,10 @@ function ResultsTable({
{listingRegister ? "All forms" : "Best matches"}
+ {/* The count is stated once, in the band above; this line only says how
+ the list is ordered. */}
- {matches.length} {matches.length === 1 ? "form" : "forms"} ·{" "}
- {sortValue === "alpha" ? "sorted A–Z" : listingRegister ? "by form code" : "ranked by relevance"}
+ {sortValue === "alpha" ? "Sorted A–Z" : listingRegister ? "By form code" : "Ranked by relevance"}
@@ -286,10 +281,7 @@ function ResultsTable({
return (
{chipLabel}
@@ -442,7 +434,7 @@ function VerifiedFooter() {
);
}
-const metaChipClass = "rounded-md px-2 py-1 text-3xs font-extrabold uppercase leading-none";
+const metaChipClass = "rounded-md px-2 py-1 text-2xs font-bold capitalize leading-none";
/**
* When the query IS a form code there is one answer, not N ranked candidates.
@@ -536,10 +528,13 @@ function MobileResultCard({ match, code }: CodedFormMatch) {
href={`/forms/${form.slug}`}
data-testid={`form-search-mobile-result-${form.slug}`}
className={cn(
- // The whole card is the target, so no per-card Open button.
- "flex items-center gap-3 p-3 transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)]",
- panelSubtle,
- searchFocusRing,
+ // The whole row is the target, so no per-row Open button. Rows are
+ // divided lines inside their group's one hairline card, not cards of
+ // their own (one card per group, never cards inside cards).
+ "flex min-h-tap items-center gap-3 px-3 py-2.5 transition hover:bg-[color:var(--clinical-accent-soft)]",
+ // Inset ring: the group card clips its corners, so an outset ring
+ // would be cut off.
+ "focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)]",
)}
>
@@ -557,7 +552,7 @@ function MobileResultCard({ match, code }: CodedFormMatch) {
{risk ? (
@@ -597,9 +592,13 @@ function MobileCards({