Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/design-system/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. First Nations keeps its own standard (v13.1), which holds its olive to the pill, current tab, icon tiles, day track and quotation accents, so its buttons stay neutral.
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 |
Expand Down
70 changes: 68 additions & 2 deletions src/app/(search-app)/documents/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,79 @@
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. In forced colours the accent becomes LinkText,
// which ButtonText cannot sit on, so the native button pair comes back.
const clinicalPrimaryAccent =
"[--command:var(--clinical-accent)] [--command-hover:var(--clinical-accent-hover)] [--command-active:var(--clinical-accent-active)] [--command-contrast:var(--clinical-accent-contrast)] forced-colors:[--command:ButtonFace] forced-colors:[--command-hover:ButtonFace] forced-colors:[--command-active:ButtonFace]";

// 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 (
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 132 84"
className="h-21 w-33 text-[color:var(--clinical-accent)]"
fill="none"
>
<rect
x="30"
y="14"
width="52"
height="64"
rx="8"
className="fill-[color:var(--clinical-accent-soft)]"
stroke="currentColor"
strokeOpacity=".35"
/>
<rect
x="50"
y="6"
width="52"
height="64"
rx="8"
className="fill-[color:var(--surface-raised)]"
stroke="currentColor"
strokeOpacity=".6"
/>
<path
d="M60 22h32M60 31h32M60 40h20"
stroke="currentColor"
strokeOpacity=".45"
strokeWidth="3"
strokeLinecap="round"
/>
<rect x="58" y="47" width="28" height="9" rx="4.5" fill="currentColor" fillOpacity=".18" />
<circle
cx="96"
cy="58"
r="13"
className="fill-[color:var(--surface-raised)]"
stroke="currentColor"
strokeWidth="3"
/>
<path d="m105 67 9 9" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" />
</svg>
);
}

export default function DocumentsSearchRoute() {
return (
<main className="mx-auto flex min-h-[55dvh] max-w-3xl flex-col items-center justify-center px-4 py-12 text-center">
<p className="text-xs font-bold uppercase tracking-eyebrow text-[color:var(--clinical-accent)]">
<DocumentsSearchIllustration />
<p className="mt-4 text-xs font-bold uppercase tracking-eyebrow text-[color:var(--clinical-accent)]">
Indexed sources
</p>
<h1 className="mt-3 text-3xl font-semibold text-[color:var(--text-heading)]">Search clinical documents</h1>
Expand All @@ -19,7 +82,10 @@ export default function DocumentsSearchRoute() {
matching page and passage.
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Link href="/?mode=documents&focus=1" className={buttonFaceClass({ variant: "primary" })}>
<Link
href="/?mode=documents&focus=1"
className={cn(buttonFaceClass({ variant: "primary" }), clinicalPrimaryAccent)}
>
Open Documents search
</Link>
</div>
Expand Down
36 changes: 36 additions & 0 deletions src/app/mode-band.css
Original file line number Diff line number Diff line change
Expand Up @@ -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]):has(.universal-header[data-clinical-band]) [data-testid="chrome-safe-area-top"],
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);
}
82 changes: 60 additions & 22 deletions src/app/work-mode.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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: "<length>";
inherits: false;
initial-value: 0px;
}

@property --work-chips-fade-end {
syntax: "<length>";
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;
Expand Down
3 changes: 1 addition & 2 deletions src/components/admin/contract/contract-end-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
createEntry,
deleteEntry,
errorWords,
JuniorBackLink,
JuniorFootNote,
JuniorNotice,
JuniorSectionLabel,
Expand Down Expand Up @@ -392,7 +391,7 @@ export function ContractEndPage({ now: nowProp }: { now?: Date } = {}) {
return (
<>
<InformationPageShell testId="admin-contract-main">
<JuniorBackLink href="/admin" label="Admin Today" testId="admin-contract-back" />
{/* One way back: the band's Today tab is Admin Today, so the page adds no second link to it. */}
<div className="grid gap-1">
<PageTitleUnderBand className="text-2xl font-semibold text-[color:var(--text-heading)]">
Contract end
Expand Down
23 changes: 13 additions & 10 deletions src/components/admin/leave/leave-wallet-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
BookOpen,
ChevronRight,
Copy,
EyeOff,
ExternalLink,
Expand All @@ -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,
Expand Down Expand Up @@ -588,7 +588,7 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {

return (
<InformationPageShell testId="admin-leave-main">
<JuniorBackLink href="/admin" label="Admin Today" testId="admin-leave-back" />
{/* One way back: the band's Today tab is Admin Today, so the page adds no second link to it. */}
<div className="grid gap-1">
<PageTitleUnderBand className="text-2xl font-semibold text-[color:var(--text-heading)]">
Leave wallet
Expand Down Expand Up @@ -664,14 +664,17 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {
Your wallet
</JuniorSectionLabel>
{/*
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.
*/}
<ul ref={stackRef} aria-label="Leave cards" className="grid" data-testid="admin-leave-stack">
<ul
ref={stackRef}
aria-label="Leave cards"
className={cn(cardSurface, "grid overflow-hidden")}
data-testid="admin-leave-stack"
>
{visible.map((type, index) => (
<li key={type.id} className={cn(index > 0 && "-mt-2")}>
<li key={type.id} className={cn(index > 0 && "border-t border-[color:var(--border)]")}>
<button
type="button"
data-card={type.id}
Expand All @@ -680,15 +683,15 @@ export function LeaveWalletPage({ now: nowProp }: { now?: Date } = {}) {
data-testid={`admin-leave-card-${type.id}`}
className={cn(
focusRing,
"relative flex min-h-16 w-full items-center gap-3 rounded-xl border focus-visible:z-10 border-[color:var(--border)] bg-[color:var(--surface-raised)] px-3 pb-3 text-left",
index > 0 ? "pt-4" : "pt-3",
"relative flex min-h-16 w-full items-center gap-3 px-3 py-2 text-left focus-visible:z-10 focus-visible:-outline-offset-2",
)}
>
<CardIcon icon={type.icon} />
<span className="grid min-w-0 flex-1">
<span className="text-sm font-semibold text-[color:var(--text-heading)]">{type.front}</span>
<span className={cn(textMuted, "text-xs")}>{type.line}</span>
</span>
<ChevronRight aria-hidden="true" className="size-icon-md shrink-0 text-[color:var(--text-muted)]" />
</button>
</li>
))}
Expand Down
8 changes: 4 additions & 4 deletions src/components/admin/renewals/kind-chips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +34,7 @@ export function ChecklistKindChips({
readonly testId?: string;
}) {
return (
<div role="group" aria-label="Filter the checklist by kind" data-testid={testId} className="work-chips flex-wrap">
<div role="group" aria-label="Filter the checklist by kind" data-testid={testId} className="work-chips">
{CHECKLIST_KIND_FILTERS.map((kind) => (
<WorkChip
key={kind}
Expand Down
Loading
Loading