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
14 changes: 1 addition & 13 deletions src/app/(search-app)/documents/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
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.
Expand Down Expand Up @@ -82,10 +73,7 @@ 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={cn(buttonFaceClass({ variant: "primary" }), clinicalPrimaryAccent)}
>
<Link href="/?mode=documents&focus=1" className={buttonFaceClass({ variant: "primary" })}>
Open Documents search
</Link>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/app/work-mode.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@
--command: var(--work-primary);
--command-hover: var(--mode-identity-deep, var(--work-primary));
--command-contrast: var(--work-primary-text);
/* The shared primary Button (button-face.ts) reads these; outside a work frame it falls back to Clinical blue. */
--action: var(--command);
--action-hover: var(--command-hover);
--action-active: var(--mode-identity-deep, var(--work-primary));
--action-contrast: var(--command-contrast);
}

/* Glass fallback. Blur is the costliest paint in the frame: on an older or
Expand Down
5 changes: 4 additions & 1 deletion src/components/my-day/my-day-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,6 @@ export function MyDayDashboard({
// ---------------------------------------------------------------- items
// The feed hides what Later moved by the snoozes kept on this device, so Needs you reads the same ones.
const needsYouDevice = needsYouList === items ? device : stored;
const needsYou = selectNeedsYou(needsYouList, needsYouDevice.snoozes, today);
// The flag repeats only what Needs you lists, so it never shows an item the bell leaves out.
const flagItems = useMemo(() => {
const listed = new Set(needsYouList.map((item) => item.id));
Expand All @@ -423,6 +422,9 @@ export function MyDayDashboard({
(id) => isSnoozed(needsYouDevice.snoozes, id, today),
);
}, [items, needsYouList, needsYouDevice.snoozes, today]);
// The item the flag shows is not listed again straight below it; with the flag hidden, Needs you keeps it.
const flaggedId = !device.hidden.has("flag") ? flagItems[0]?.id : undefined;
const needsYou = selectNeedsYou(needsYouList, needsYouDevice.snoozes, today, undefined, flaggedId);
const tomorrow = addDaysToDate(today, 1);
// The next rostered day, offered by Later only when it is not tomorrow (and only from a roster that loaded).
const workingDay = useMemo(
Expand Down Expand Up @@ -716,6 +718,7 @@ export function MyDayDashboard({
shown={needsYou.shown}
waiting={needsYou.waiting}
total={needsYou.total}
flagged={flaggedId !== undefined}
checked={checked}
checkedAt={checkedAt}
incomplete={incomplete}
Expand Down
13 changes: 7 additions & 6 deletions src/components/my-day/my-day-today-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,7 @@ export function NeedsYouCard({
shown,
waiting,
total,
flagged = false,
checked,
checkedAt,
incomplete = false,
Expand All @@ -1521,6 +1522,8 @@ export function NeedsYouCard({
readonly shown: readonly MyDayNeedsYouItem[];
readonly waiting: number;
readonly total: number;
/** The flag card above shows one waiting item, so it is counted here but not listed again. */
readonly flagged?: boolean;
readonly checked: readonly string[];
/** When the sources were last checked, "14:05". */
readonly checkedAt?: string | null;
Expand Down Expand Up @@ -1577,7 +1580,7 @@ export function NeedsYouCard({
className="m-0 px-3.5 py-3 text-xs font-semibold text-[color:var(--text-muted)]"
data-testid="my-day-needs-you-snoozed"
>
{`${incomplete ? "Nothing else found in the sources that loaded." : "Nothing else needs you today."} ${total - waiting} hidden until tomorrow.`}
{`${incomplete ? "Nothing else found in the sources that loaded." : "Nothing else needs you today."}${total > waiting ? ` ${total - waiting} hidden until tomorrow.` : ""}`}
</p>
);
} else {
Expand All @@ -1590,12 +1593,10 @@ export function NeedsYouCard({
);
}
const atLeast = incomplete ? "at least " : "";
// The count describes the rows below, so the item already in the flag is not counted twice.
const listed = flagged ? Math.max(waiting - 1, 0) : waiting;
const count =
waiting === 0
? undefined
: waiting > shown.length
? `${atLeast}${shown.length} of ${waiting}`
: `${atLeast}${waiting}`;
listed === 0 ? undefined : listed > shown.length ? `${atLeast}${shown.length} of ${listed}` : `${atLeast}${listed}`;
const stampTone = checked.length === 0 ? "off" : incomplete ? "warn" : "ok";
const stamp =
checked.length === 0
Expand Down
17 changes: 1 addition & 16 deletions src/components/specifiers/specifier-builder-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,6 @@
} from "@/lib/specifiers";
import { copyButton, errorCopy, specifierBuilderCopy } from "@/lib/ui-copy";

// 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]";

const specifierFamilyOrder: Record<SpecifierFamily, number> = {
"episode-features": 0,
"course-onset": 1,
Expand Down Expand Up @@ -882,64 +874,57 @@
</button>
<Button
type="button"
variant="primary"
className={clinicalPrimaryAccent}
onClick={() => void copyWording()}
icon={copyState === "copied" ? Check : ClipboardCopy}
>
{copyState === "copied" ? copyButton.copied : specifierBuilderCopy.review.copy}
</Button>
</div>
<p role="status" className="sr-only">
{copyState === "copied"
? specifierBuilderCopy.review.copied
: copyState === "failed"
? errorCopy.clipboardCopyFailed
: ""}
</p>

<div className="flex flex-wrap justify-end gap-2 border-t border-[color:var(--border)] pt-4">
<Link
href="/specifiers/map"
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text)] motion-reduce:transition-none"
>
<Waypoints className="h-4 w-4" aria-hidden />
Browse the map
</Link>
<Link
href={
curatedCompareSlugs.length >= 2
? pairCompareHref("/specifiers/compare", curatedCompareSlugs[0], curatedCompareSlugs[1])
: "/specifiers/compare"
}
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-4 text-sm font-bold text-[color:var(--text)] motion-reduce:transition-none"
>
<GitCompareArrows className="h-4 w-4" aria-hidden />
Compare specifiers
</Link>
</div>
</div>
</section>
) : null}

{activeView !== "review" ? (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-3">
<button
type="button"
onClick={() => move(-1)}
disabled={activeIndex === 0}
className="inline-flex min-h-tap items-center gap-2 rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-4 text-sm font-semibold text-[color:var(--text-muted)] disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-[color:var(--surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
<ChevronLeft className="h-4 w-4" aria-hidden />
{specifierBuilderCopy.navigation.previous}
</button>
<Button
type="button"
variant="primary"
className={clinicalPrimaryAccent}
onClick={() => move(1)}
trailingIcon={ArrowRight}
>
<Button type="button" variant="primary" onClick={() => move(1)} trailingIcon={ArrowRight}>

Check notice on line 927 in src/components/specifiers/specifier-builder-page.tsx

View check run for this annotation

CodeScene Access / CodeScene Code Health Review (main)

✅ Getting better: Large Method

SpecifierBuilderPage decreases from 721 to 714 lines of code, threshold = 120 Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
{continueLabelFor(steps, activeIndex)}
</Button>
</div>
Expand Down
9 changes: 7 additions & 2 deletions src/components/ui/button-face.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ import { cn, controlBase } from "@/components/ui-primitives";
export type ButtonVariant = "primary" | "secondary" | "toolbar" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";

// One filled `--command` button per surface (register #12). `secondary` is the
// One filled primary button per surface (register #12). It takes the area's
// colour (C8 rule 3): Clinical blue by default, and a Work area's own colour
// where its frame sets `--action` (work-mode.css). The button re-points the
// command tokens on itself, so hover and pressed states follow and nothing else
// on the page changes. High-contrast mode keeps the native button pair, since
// the blue there is LinkText and ButtonText cannot sit on it. `secondary` is the
// default for everything that is not the single primary action on screen.
//
// `danger` is the ONLY home for `--danger-solid` (register #13): a destructive
// action is the one place a filled red is not decoration. Do not reach for it to
// mean "important" — importance is `primary`.
const VARIANT: Record<ButtonVariant, string> = {
primary:
"bg-[color:var(--command)] text-[color:var(--command-contrast)] shadow-[var(--e1)] hover:bg-[color:var(--command-hover)] hover:shadow-[var(--e3)] active:bg-[color:var(--command-active)]",
"[--command:var(--action,var(--clinical-accent))] [--command-hover:var(--action-hover,var(--clinical-accent-hover))] [--command-active:var(--action-active,var(--clinical-accent-active))] [--command-contrast:var(--action-contrast,var(--clinical-accent-contrast))] forced-colors:[--command:ButtonFace] forced-colors:[--command-hover:ButtonFace] forced-colors:[--command-active:ButtonFace] forced-colors:[--command-contrast:ButtonText] bg-[color:var(--command)] text-[color:var(--command-contrast)] shadow-[var(--e1)] hover:bg-[color:var(--command-hover)] hover:shadow-[var(--e3)] active:bg-[color:var(--command-active)]",
secondary:
"border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]",
toolbar:
Expand Down
6 changes: 5 additions & 1 deletion src/lib/my-day/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,19 @@ export function nextRosteredDay(
* The "Needs you" card: the items (already in order) less anything moved to
* later, capped. `total` is every item, which "All N" opens. An item Later
* cannot move (`snoozable: false`) always shows, the same rule the bell uses.
* `flaggedId` is the item the flag card above already shows: it is left out of
* the rows but still counted, so "All N" matches the full list it opens.
*/
export function selectNeedsYou<T extends { readonly id: string; readonly snoozable?: boolean }>(
items: readonly T[],
snoozes: MyDaySnoozes,
today: string,
cap: number = MY_DAY_NEEDS_YOU_CAP,
flaggedId?: string,
): { readonly shown: readonly T[]; readonly waiting: number; readonly total: number } {
const waiting = items.filter((item) => item.snoozable === false || !isSnoozed(snoozes, item.id, today));
return { shown: waiting.slice(0, cap), waiting: waiting.length, total: items.length };
const rows = flaggedId ? waiting.filter((item) => item.id !== flaggedId) : waiting;
return { shown: rows.slice(0, cap), waiting: waiting.length, total: items.length };
}

/** One timed thing today, from any source, as the "Up next" card and the agenda show it. */
Expand Down
66 changes: 47 additions & 19 deletions tests/my-day-dashboard.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ describe("MyDayDashboard cards", () => {
render(
<MyDayDashboard
{...props({
items: [item("a", "soon")],
// Info, so the flag card (overdue or soon only) leaves the one row in Needs you to count.
items: [item("a", "info")],
sources: {
...EMPTY_SOURCES,
roster: { status: "failed", shifts: [], sample: false },
Expand Down Expand Up @@ -470,8 +471,8 @@ describe("MyDayDashboard cards", () => {

describe("Needs you", () => {
const five = [
item("a", "overdue", { detail: "Routine due" }),
item("b", "overdue"),
item("a", "overdue"),
item("b", "overdue", { detail: "Routine due" }),
item("c", "soon"),
item("d", "soon"),
item("e", "info"),
Expand All @@ -484,13 +485,15 @@ describe("Needs you", () => {
const rows = [...card.querySelectorAll('[data-testid^="my-day-item-"]')].map((row) =>
row.getAttribute("data-testid"),
);
expect(rows).toEqual(["my-day-item-a", "my-day-item-b", "my-day-item-c"]);
// "a" leads the flag card above, so Needs you starts at the next overdue row.
expect(rows).toEqual(["my-day-item-b", "my-day-item-c", "my-day-item-d"]);
// Two lines a row (design review item 8): the detail line only repeated the state.
expect(within(card).queryByText("Routine due")).toBeNull();
// The row's one action opens the item, carrying the way back to My Day.
expect(screen.getByTestId("my-day-open-a").getAttribute("href")).toBe("/admin/a?from=my-day");
expect(screen.getByTestId("my-day-open-a").textContent).toBe("Open");
expect(card.textContent).toContain("Needs you3 of 5");
expect(screen.getByTestId("my-day-open-b").getAttribute("href")).toBe("/admin/b?from=my-day");
expect(screen.getByTestId("my-day-open-b").textContent).toBe("Open");
// The count describes the rows below (the flagged one is not counted twice); All N still opens all five.
expect(card.textContent).toContain("Needs you3 of 4");
fireEvent.click(within(card).getByRole("button", { name: "See all 5" }));
expect(onShowAll).toHaveBeenCalledTimes(1);
});
Expand All @@ -499,30 +502,32 @@ describe("Needs you", () => {
render(<MyDayDashboard {...props({ items: five })} />);
// axe target-size (WCAG 2.5.8): Later's 48px box is pulled up under the pill, so the
// pill is raised over it and its tap layer ends at its own bottom edge.
const pill = screen.getByTestId("my-day-open-a");
const pill = screen.getByTestId("my-day-open-b");
expect(pill.className).toContain("z-10");
expect(pill.className).toContain("before:bottom-0");
expect(screen.getByRole("button", { name: "Later: Title a" }).className).toContain("min-h-12");
expect(screen.getByRole("button", { name: "Later: Title b" }).className).toContain("min-h-12");
});

it("moves a row to tomorrow on this device with Later, and Undo brings it back", () => {
render(<MyDayDashboard {...props({ items: five })} />);
fireEvent.click(screen.getByRole("button", { name: "Later: Title a" }));
expect(screen.queryByTestId("my-day-item-a")).toBeNull();
// "a" is in the flag card, so Later acts on Needs you's first row, "b".
fireEvent.click(screen.getByRole("button", { name: "Later: Title b" }));
expect(screen.queryByTestId("my-day-item-b")).toBeNull();
// The next row moves up so the card still shows three.
expect(screen.getByTestId("my-day-item-d")).toBeTruthy();
expect(screen.getByTestId("my-day-item-e")).toBeTruthy();
// work-mode redesign, owner request 6 Oct 2026: the note under Needs you reads "1 hidden until tomorrow" over the item's title.
expect(screen.getByRole("status").textContent).toContain("1 hidden until tomorrow");
expect(screen.getByRole("status").textContent).toContain("Title a");
expect(JSON.parse(window.localStorage.getItem(MY_DAY_SNOOZED_ITEMS_STORAGE_KEY)!)).toEqual({ a: "2026-10-04" });
expect(screen.getByRole("status").textContent).toContain("Title b");
expect(JSON.parse(window.localStorage.getItem(MY_DAY_SNOOZED_ITEMS_STORAGE_KEY)!)).toEqual({ b: "2026-10-04" });

fireEvent.click(screen.getByRole("button", { name: "Undo" }));
expect(screen.getByTestId("my-day-item-a")).toBeTruthy();
expect(screen.getByTestId("my-day-item-b")).toBeTruthy();
expect(window.localStorage.getItem(MY_DAY_SNOOZED_ITEMS_STORAGE_KEY)).toBeNull();
});

it("never says nothing else needs you when every row is hidden but a source failed", () => {
render(<MyDayDashboard {...props({ items: [item("a", "soon")], incomplete: true })} />);
// Info, so no flag card takes the one row out of Needs you.
render(<MyDayDashboard {...props({ items: [item("a", "info")], incomplete: true })} />);
fireEvent.click(screen.getByRole("button", { name: "Later: Title a" }));
expect(screen.getByTestId("my-day-needs-you-snoozed").textContent).toBe(
"Nothing else found in the sources that loaded. 1 hidden until tomorrow.",
Expand All @@ -531,14 +536,36 @@ describe("Needs you", () => {

it("shows a moved row again from the day it comes back", () => {
window.localStorage.setItem(MY_DAY_SNOOZED_ITEMS_STORAGE_KEY, JSON.stringify({ a: "2026-10-04" }));
render(<MyDayDashboard {...props({ items: [item("a", "overdue")] })} />);
// Info, so on its return day the row comes back to Needs you rather than to the flag card.
render(<MyDayDashboard {...props({ items: [item("a", "info")] })} />);
expect(screen.queryByTestId("my-day-item-a")).toBeNull();
expect(screen.getByTestId("my-day-needs-you-snoozed").textContent).toContain("1 hidden until tomorrow");
cleanup();
render(<MyDayDashboard {...props({ items: [item("a", "overdue")], today: "2026-10-04" })} />);
render(<MyDayDashboard {...props({ items: [item("a", "info")], today: "2026-10-04" })} />);
expect(screen.getByTestId("my-day-item-a")).toBeTruthy();
});

it("leaves the flagged item out of Needs you while the flag shows, and lists it again when the flag is hidden", () => {
render(<MyDayDashboard {...props({ items: five })} />);
expect(within(screen.getByTestId("my-day-card-flag")).getByText(/Title a/)).toBeTruthy();
let card = screen.getByTestId("my-day-card-needs-you");
expect(within(card).queryByTestId("my-day-item-a")).toBeNull();
// Still counted: All N matches the full list it opens.
expect(within(card).getByRole("button", { name: "See all 5" })).toBeTruthy();
cleanup();

window.localStorage.setItem(MY_DAY_HIDDEN_CARDS_STORAGE_KEY, JSON.stringify(["flag"]));
render(<MyDayDashboard {...props({ items: five })} />);
expect(screen.queryByTestId("my-day-card-flag")).toBeNull();
card = screen.getByTestId("my-day-card-needs-you");
const rows = [...card.querySelectorAll('[data-testid^="my-day-item-"]')].map((row) =>
row.getAttribute("data-testid"),
);
expect(rows).toEqual(["my-day-item-a", "my-day-item-b", "my-day-item-c"]);
expect(card.textContent).toContain("Needs you3 of 5");
expect(within(card).getByRole("button", { name: "See all 5" })).toBeTruthy();
});

it("changes nothing on the server: Later and Undo make no request", () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
Expand Down Expand Up @@ -609,7 +636,8 @@ describe("Customise", () => {
it("forgets hidden cards and moved rows at an account transition", () => {
window.localStorage.setItem(MY_DAY_HIDDEN_CARDS_STORAGE_KEY, JSON.stringify(["quick-actions"]));
window.localStorage.setItem(MY_DAY_SNOOZED_ITEMS_STORAGE_KEY, JSON.stringify({ a: "2026-10-04" }));
render(<MyDayDashboard {...props({ items: [item("a", "overdue")] })} />);
// Info, so once the move is forgotten the row returns to Needs you rather than to the flag card.
render(<MyDayDashboard {...props({ items: [item("a", "info")] })} />);
expect(screen.queryByTestId("my-day-card-quick-actions")).toBeNull();
expect(screen.queryByTestId("my-day-item-a")).toBeNull();

Expand Down
Loading
Loading