diff --git a/src/app/(search-app)/documents/search/page.tsx b/src/app/(search-app)/documents/search/page.tsx index c893789951..da59415164 100644 --- a/src/app/(search-app)/documents/search/page.tsx +++ b/src/app/(search-app)/documents/search/page.tsx @@ -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. @@ -82,10 +73,7 @@ export default function DocumentsSearchRoute() { matching page and passage.

- + Open Documents search
diff --git a/src/app/work-mode.css b/src/app/work-mode.css index 4b41df967c..c641100884 100644 --- a/src/app/work-mode.css +++ b/src/app/work-mode.css @@ -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 diff --git a/src/components/my-day/my-day-dashboard.tsx b/src/components/my-day/my-day-dashboard.tsx index d365390b64..7fcc7cc525 100644 --- a/src/components/my-day/my-day-dashboard.tsx +++ b/src/components/my-day/my-day-dashboard.tsx @@ -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)); @@ -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( @@ -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} diff --git a/src/components/my-day/my-day-today-cards.tsx b/src/components/my-day/my-day-today-cards.tsx index 9cc49b0623..90c9589c44 100644 --- a/src/components/my-day/my-day-today-cards.tsx +++ b/src/components/my-day/my-day-today-cards.tsx @@ -1503,6 +1503,7 @@ export function NeedsYouCard({ shown, waiting, total, + flagged = false, checked, checkedAt, incomplete = false, @@ -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; @@ -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.` : ""}`}

); } else { @@ -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 diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index 5cbcb3f5a2..e17615342c 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -52,14 +52,6 @@ import { } 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 = { "episode-features": 0, "course-onset": 1, @@ -883,7 +875,6 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif - diff --git a/src/components/ui/button-face.ts b/src/components/ui/button-face.ts index 5490da351e..b86a4c182c 100644 --- a/src/components/ui/button-face.ts +++ b/src/components/ui/button-face.ts @@ -3,7 +3,12 @@ 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 @@ -11,7 +16,7 @@ export type ButtonSize = "sm" | "md" | "lg"; // mean "important" — importance is `primary`. const VARIANT: Record = { 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: diff --git a/src/lib/my-day/dashboard.ts b/src/lib/my-day/dashboard.ts index aa43dd96e9..030cef22b0 100644 --- a/src/lib/my-day/dashboard.ts +++ b/src/lib/my-day/dashboard.ts @@ -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( 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. */ diff --git a/tests/my-day-dashboard.dom.test.tsx b/tests/my-day-dashboard.dom.test.tsx index 019fb773ce..2f58c2de58 100644 --- a/tests/my-day-dashboard.dom.test.tsx +++ b/tests/my-day-dashboard.dom.test.tsx @@ -414,7 +414,8 @@ describe("MyDayDashboard cards", () => { render( { 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"), @@ -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); }); @@ -499,30 +502,32 @@ describe("Needs you", () => { render(); // 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(); - 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(); + // Info, so no flag card takes the one row out of Needs you. + render(); 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.", @@ -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(); + // Info, so on its return day the row comes back to Needs you rather than to the flag card. + render(); expect(screen.queryByTestId("my-day-item-a")).toBeNull(); expect(screen.getByTestId("my-day-needs-you-snoozed").textContent).toContain("1 hidden until tomorrow"); cleanup(); - render(); + render(); 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(); + 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(); + 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); @@ -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(); + // Info, so once the move is forgotten the row returns to Needs you rather than to the flag card. + render(); expect(screen.queryByTestId("my-day-card-quick-actions")).toBeNull(); expect(screen.queryByTestId("my-day-item-a")).toBeNull(); diff --git a/tests/my-day-dashboard.test.ts b/tests/my-day-dashboard.test.ts index b86fc0a2a2..44dc4c534b 100644 --- a/tests/my-day-dashboard.test.ts +++ b/tests/my-day-dashboard.test.ts @@ -92,6 +92,19 @@ describe("selectNeedsYou", () => { expect(moved).toMatchObject({ waiting: 4, total: 5 }); expect(selectNeedsYou(items, { a: "2026-10-04" }, "2026-10-04").shown[0]?.id).toBe("a"); }); + + it("leaves the flagged item out of the rows but still counts it", () => { + const items = [item("a", "overdue"), item("b", "overdue"), item("c", "soon"), item("d"), item("e")]; + const flagged = selectNeedsYou(items, {}, TODAY, undefined, "a"); + expect(flagged.shown.map((row) => row.id)).toEqual(["b", "c", "d"]); + expect(flagged).toMatchObject({ waiting: 5, total: 5 }); + // A moved row and the flagged one both stay out; the moved one alone leaves waiting. + const moved = selectNeedsYou(items, { b: "2026-10-04" }, TODAY, undefined, "a"); + expect(moved.shown.map((row) => row.id)).toEqual(["c", "d", "e"]); + expect(moved).toMatchObject({ waiting: 4, total: 5 }); + // No flag: the rows are unchanged. + expect(selectNeedsYou(items, {}, TODAY, undefined, undefined).shown.map((row) => row.id)).toEqual(["a", "b", "c"]); + }); }); describe("selectUpNext", () => { diff --git a/tests/my-day-page.dom.test.tsx b/tests/my-day-page.dom.test.tsx index 2b08df733a..33fa716ff2 100644 --- a/tests/my-day-page.dom.test.tsx +++ b/tests/my-day-page.dom.test.tsx @@ -160,7 +160,8 @@ describe("MyDayPage", () => { it("runs the sources in a local demo build with no sign-in configured", () => { auth.status = "unconfigured"; - setState({ items: [item("a", "soon")] }); + // Info, so the flag card (overdue or soon only) leaves the row in Needs you. + setState({ items: [item("a", "info")] }); render(); expect(screen.getByTestId("my-day-item-a")).toBeTruthy(); expect(screen.queryByTestId("my-day-signed-out")).toBeNull(); @@ -276,8 +277,9 @@ describe("MyDayPage", () => { }); it("names failed sources, keeps the rest, and retries on click", () => { + // Info, so the flag card (overdue or soon only) leaves the row in Needs you. setState({ - items: [item("a", "soon")], + items: [item("a", "info")], sources: readySources.map((source) => source.mode === "roster" || source.mode === "cme" ? { ...source, status: "failed" as const } : source, ), @@ -307,7 +309,8 @@ describe("MyDayPage", () => { // Demo data is shown only in a local demo build with no sign-in ("unconfigured"). it("draws no example notice in a local demo build", () => { auth.status = "unconfigured"; - setState({ demoMode: true, items: [item("a", "soon")] }); + // Info, so the flag card (overdue or soon only) leaves the row in Needs you. + setState({ demoMode: true, items: [item("a", "info")] }); render(); expect(screen.queryByTestId("my-day-demo-notice")).toBeNull(); expect(screen.getByTestId("my-day-item-a")).toBeTruthy(); @@ -333,7 +336,8 @@ describe("MyDayPage", () => { it("never shows a signed-in reader items from a source that answered with sample data", () => { setState({ demoMode: true, - items: [item("a", "soon", { mode: "cme" }), item("b", "soon")], + // "b" is info, so the flag card (overdue or soon only) leaves it in Needs you. + items: [item("a", "soon", { mode: "cme" }), item("b", "info")], sources: readySources.map((source) => (source.mode === "cme" ? { ...source, sample: true } : source)), }); const { rerender } = render(); @@ -347,12 +351,15 @@ describe("MyDayPage", () => { // Design review 2026-10-03, item 1: the full list has its own address, so the // phone's Back returns to the dashboard instead of leaving My Day. it("opens the full list at its own address and steps back to the dashboard", () => { - setState({ items: [item("a", "overdue"), item("b", "soon"), item("c", "soon"), item("d", "info")] }); + // "a" leads the flag card, so Needs you lists b, c and e, and d is only in the full list. + setState({ + items: [item("a", "overdue"), item("b", "soon"), item("c", "soon"), item("e", "soon"), item("d", "info")], + }); const { rerender } = render(); expect(screen.getByTestId("my-day-dashboard")).toBeTruthy(); expect(screen.queryByTestId("my-day-item-d")).toBeNull(); const before = window.history.length; - openAll("See all 4", rerender); + openAll("See all 5", rerender); expect(window.history.length).toBe(before + 1); expect(screen.getByTestId("my-day-full-list")).toBeTruthy(); expect(screen.getByTestId("my-day-item-d")).toBeTruthy(); @@ -460,7 +467,8 @@ describe("MyDayPage", () => { }, ]), ); - setState({ items: [item("a", "soon")] }); + // Info, so the flag card (overdue or soon only) leaves "a" in Needs you beside the reminder. + setState({ items: [item("a", "info")] }); render(); const card = screen.getByTestId("my-day-card-needs-you"); expect(within(card).getByTestId("my-day-item-remind:r1").textContent).toContain("Reminders"); @@ -499,7 +507,8 @@ describe("MyDayPage", () => { // so My Day never says nothing needs you before the feed has looked. it("keeps the skeleton until Needs you's feed has answered", () => { readerLoaded.current = false; - setState({ items: [item("a", "soon")] }); + // Info, so the flag card (overdue or soon only) leaves the row in Needs you. + setState({ items: [item("a", "info")] }); const { rerender } = render(); expect(screen.getByTestId("my-day-loading")).toBeTruthy(); expect(screen.queryByTestId("my-day-ready")).toBeNull();