From 1e534dfbf2182dafb235eb2a284996d80b4b305c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:10:04 +0800 Subject: [PATCH 01/16] fix(design-system): pay down Hazard 1-2 contract debt Remaining Lucide size={15} and 1px shadow spreads were blocking the token contract. Map those to named tokens, drop unused v2 line-height companions, and record leftover Hazard 2/3 items in FIX-GUIDE. Co-authored-by: Cursor --- docs/design-system-contract.md | 2 +- docs/design-system/COMPONENTS.md | 6 +- docs/design-system/FIX-GUIDE.md | 94 +++++++++++++++++++ docs/design-system/GATES.md | 12 +-- docs/design-system/README.md | 1 + docs/design-system/TOKENS.md | 9 +- scripts/design-system-contract-baseline.json | 72 ++++++-------- scripts/design-system-contract-utils.mjs | 31 ++++++ src/app/ckb-v2-tokens.css | 23 +---- src/app/globals.css | 13 ++- .../differential-detail-page.tsx | 4 +- .../specifiers/specifier-builder-page.tsx | 6 +- src/components/specifiers/specifier-ui.tsx | 6 +- .../therapy-compass/screens/brief-screen.tsx | 5 +- .../screens/compare-screen.tsx | 6 +- .../screens/recommend-screen.tsx | 2 +- .../therapy-compass/screens/sheets-screen.tsx | 6 +- .../therapy-compass/therapy-card.tsx | 5 +- tests/ckb-v2-token-contract.test.ts | 13 ++- tests/design-system-contract-utils.test.ts | 46 +++++++++ 20 files changed, 250 insertions(+), 112 deletions(-) create mode 100644 docs/design-system/FIX-GUIDE.md diff --git a/docs/design-system-contract.md b/docs/design-system-contract.md index ce96a3b554..850ab04813 100644 --- a/docs/design-system-contract.md +++ b/docs/design-system-contract.md @@ -29,7 +29,7 @@ npm run check:icon-scale - **Text Roles**: `var(--text)`, `var(--text-muted)`, `var(--text-heading)`, `var(--text-soft)` - **Status & Safety Triads**: `--success-*`, `--warning-*`, `--danger-*`, `--info-*` (reserved exclusively for clinical/system status). - **Focus Ring & Outlines**: `var(--focus)` for all keyboard and visible focus rings. -- **Raw Color Exemptions**: Strict and enumerated in `RAW_COLOR_EXEMPTIONS` in `scripts/design-system-contract-utils.mjs` (e.g., globals token definitions, brand mark SVG builder, diagnostic visualizations, OpenGraph art, printable patient/factsheet paper). +- **Raw Color Exemptions**: Strict and enumerated in `RAW_COLOR_EXEMPTIONS` in `scripts/design-system-contract-utils.mjs` (e.g., globals token definitions, brand mark SVG builder, diagnostic visualizations, OpenGraph art, printable patient/factsheet paper). Medication record accent defaults (`#0f766e` in `src/lib/medications.ts` and `src/lib/medication-records.ts`) are a **scoped** exemption for the Postgres `accent` column default only — not a whole-file blank cheque, and not a mapping onto `--clinical-accent`. ### 2.2 Typography Scale diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index e1a2b7cb4e..72afab134c 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -20,7 +20,7 @@ derived from source, `.design-sync/config.json`, previews, direct contract cover production imports. It intentionally does not claim dark, forced-colours, 320px, print, remote publication, or product adoption without separate evidence. -### 0.1 Registered visual components (54 — local contract; remote status unverified) +### 0.1 Registered visual components (55 — local contract; remote status unverified) Every visual export has one source map entry, a source-derived public `*Props` contract (except the two zero-prop roots), a reference preview, and a direct static publication test. The generated @@ -37,7 +37,7 @@ table under **Generated maturity snapshot** is the canonical list and product-im _Remaining from the original eight in this document:_ `DocumentFrame` is built locally (`src/components/ui/document-frame.tsx`, shell-only in `DocumentViewer`) but is not yet among -the 54 design-sync registered visual exports — registration and full controls remain follow-up. +the 55 design-sync registered visual exports — registration and full controls remain follow-up. `OverlayRoot`, `SegmentedControl`, and the PR 6–8 components are built and represented by the local publication contract. The generated snapshot records their current product-import counts; @@ -86,7 +86,7 @@ print primitives (`PrintHeader`, `PrintFooter`, `CitationFootnote`, `PrintOnly`, | Progress/StageList | all four resolved: `scaleX`, the theme `animate-shimmer` in place of a hardcoded `1.4s`, a step index clamped to ≥1, and an sr-only `role="status"` sibling in place of `aria-live` on the whole `
    ` | **done** | | EmptyState | static live-off default with explicit polite/assertive opt-in | **done** (PR 8) | | AccessibleTable | semantic caption, `MissingValue` cells, dense headers (clipped header keeps its full string as `title`) and the expander (now the registered `Button`, off the local ring-focus recipe) landed; content-role widths remain | PR 6/PR 12 remainder | -| ui-primitives.tsx | 698-line module mixing recipes/actions/feedback/clinical — split. Re-measured 2026-08-12: **157** production files import it (202 including mockups), against the 54 registered components' **31** with product imports; this module, not the registry, is what the product actually runs on. Per-primitive breakdown added 2026-08-21: `Button` **12** importers against 157, with **112** production files still holding a raw ` + + {currentPage} / {totalPages} + + + + )} + + +
      + {currentRecords.map((record, index) => { + const globalIndex = startIndex + index; + return ( +
    1. +
      + {record.date} + {record.ref} + {record.head} +
      +

      {record.scope}

      +

      {record.outcome}

      +
      + Checks run +

      {record.checks}

      +
      +
    2. + ); + })} +
    + + {totalPages > 1 && ( +
    +
    + + + {currentPage} / {totalPages} + + +
    +
    + )} + + ); +} diff --git a/src/components/mode-home-page-skeleton.tsx b/src/components/mode-home-page-skeleton.tsx index f198a09375..ce1548919b 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -29,7 +29,7 @@ export function ModeHomeRouteLoading() { return ( // Match ModeHomeMain startOnPhone: top-align on phones, centre from sm up. // A phone-centred skeleton jumped when content-rich homes mounted top-aligned. -
    +
    ); @@ -57,7 +57,7 @@ export function DocumentSearchPageSkeleton() { export function DocumentViewerPageSkeleton() { return (
    diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 1f5137aef1..708b9a8e7f 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -220,14 +220,13 @@ export const searchPageCanvas = "bg-[color:var(--background)] text-[color:var(-- // Phone bottom-dock clearance lives on #main-content / dashboard
    via // --mobile-composer-reserve so it can collapse when the dock hides. Do not bake // a second dock-sized safe-area pad into page shells. -export const searchPageShell = - "min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; +export const searchPageShell = "min-h-0 w-full flex-1 overflow-x-clip px-3 py-3 pb-4 sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; // Standalone pages outside the search shell own the OS top inset themselves // (apple-mobile-web-app-status-bar-style=black-translucent). Bake max(safe-area) // into the top pad and omit py-* so cn() call sites never rely on Tailwind's // side-vs-axis utility sort order to win over searchPageShell's py-3/sm:py-5. export const searchPageShellStandalone = - "min-h-0 overflow-x-clip px-3 pt-[max(0.75rem,var(--safe-area-top))] pb-4 sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 sm:pt-[max(1.25rem,var(--safe-area-top))] sm:pb-8 lg:px-6"; + "min-h-0 w-full flex-1 overflow-x-clip px-3 pt-[max(0.75rem,var(--safe-area-top))] pb-4 sm:px-5 sm:pt-[max(1.25rem,var(--safe-area-top))] sm:pb-8 lg:px-6"; export const searchPageContainer = "mx-auto w-full max-w-[1500px]"; // Canonical content-page width. Detail pages (service / form / differential), // medication record + prescribing workspace, and the forms results view converge diff --git a/src/components/ward-management/ward-management-modes.module.css b/src/components/ward-management/ward-management-modes.module.css index 271a3884f3..d867708011 100644 --- a/src/components/ward-management/ward-management-modes.module.css +++ b/src/components/ward-management/ward-management-modes.module.css @@ -531,6 +531,22 @@ .summaryCard { padding: var(--ward-space-12); + text-decoration: none; +} + +.summaryLinkCard { + display: block; + cursor: pointer; + transition: background-color var(--duration-fast, 150ms) ease; +} + +.summaryLinkCard:hover { + background: var(--ward-surface-hover, var(--surface-subtle)); +} + +.summaryLinkCard:focus-visible { + outline: 0.1875rem solid var(--ward-blue); + outline-offset: 0.125rem; } .summaryCard span, diff --git a/src/components/ward-management/ward-management-modes.tsx b/src/components/ward-management/ward-management-modes.tsx index 82181f985a..fdf2f0b417 100644 --- a/src/components/ward-management/ward-management-modes.tsx +++ b/src/components/ward-management/ward-management-modes.tsx @@ -411,10 +411,21 @@ function CapacityView() { // Five cards, named explicitly rather than derived from `Object.entries` — that keeps this // list exactly the five figures spec D6 names, in the order it names them, and makes a sixth // "total" card impossible to add by accident the way looping over a totals object invited. - const headlineCards: { key: string; label: string; value: number }[] = [ + // Spec D9 (#WG24JB): confirmed and predicted pending discharge cards link directly to the discharge board. + const headlineCards: { key: string; label: string; value: number; href?: string }[] = [ { key: "available-now", label: "Available now", value: headline.availableNow }, - { key: "confirmed-today", label: "Confirmed today", value: headline.confirmedToday }, - { key: "predicted-today", label: "Predicted today", value: headline.predictedToday }, + { + key: "confirmed-today", + label: "Confirmed today", + value: headline.confirmedToday, + href: "/mockups/ward-flow/discharges", + }, + { + key: "predicted-today", + label: "Predicted today", + value: headline.predictedToday, + href: "/mockups/ward-flow/discharges", + }, { key: "held", label: "Held", value: headline.held }, { key: "leave-usable", label: "Leave (usable)", value: headline.leaveUsable }, ]; @@ -432,13 +443,33 @@ function CapacityView() { Synthetic counts
    - {headlineCards.map(({ key, label, value }) => ( -
    - {label} - {value} - Across {units.length} synthetic units -
    - ))} + {headlineCards.map(({ key, label, value, href }) => { + const content = ( + <> + {label} + {value} + Across {units.length} synthetic units + + ); + if (href) { + return ( + + {content} + + ); + } + return ( +
    + {content} +
    + ); + })}
    {excludedBeyondToday > 0 && (

    diff --git a/src/lib/developer-area/freshness.ts b/src/lib/developer-area/freshness.ts index 3fdea0de35..e3a9ef9db8 100644 --- a/src/lib/developer-area/freshness.ts +++ b/src/lib/developer-area/freshness.ts @@ -1,9 +1,32 @@ +export type FreshnessMode = "snapshot" | "live"; + /** * Deliberately free of any one snapshot's shape. Phase 1 kept this type inside * `ledger-snapshot.ts`, which made a second snapshot import ledger code to * describe its own age. */ -export type Freshness = { contentAt: string | null; viewedAt: string; ageHours: number | null }; +export type Freshness = { + contentAt: string | null; + viewedAt: string; + ageHours: number | null; + mode?: FreshnessMode; +}; + +/** + * Formats a duration in milliseconds into clean relative prose: + * - < 1 minute: "just now" + * - < 60 minutes: "< 1 hour ago" + * - >= 60 minutes: "N hour(s) ago" + */ +export function formatRelativeAge(diffMs: number): string { + if (diffMs < 0) return "just now"; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return "< 1 hour ago"; + const hours = Math.floor(minutes / 60); + return `${hours} ${hours === 1 ? "hour" : "hours"} ago`; +} /** * `ageHours` is null for a missing OR unparseable content date. A NaN age would @@ -11,10 +34,19 @@ export type Freshness = { contentAt: string | null; viewedAt: string; ageHours: * carrying no information, which is the failure that component exists to * prevent. */ -export function resolveFreshnessFrom(contentAt: string | null, now: Date): Freshness { +export function resolveFreshnessFrom(contentAt: string | null, now: Date, mode: FreshnessMode = "snapshot"): Freshness { const viewedAt = now.toISOString(); - if (contentAt === null) return { contentAt, viewedAt, ageHours: null }; + if (contentAt === null) return { contentAt, viewedAt, ageHours: null, mode }; const parsed = new Date(contentAt); - if (Number.isNaN(parsed.getTime())) return { contentAt, viewedAt, ageHours: null }; - return { contentAt, viewedAt, ageHours: Math.round((now.getTime() - parsed.getTime()) / 3_600_000) }; + if (Number.isNaN(parsed.getTime())) return { contentAt, viewedAt, ageHours: null, mode }; + return { + contentAt, + viewedAt, + ageHours: Math.round((now.getTime() - parsed.getTime()) / 3_600_000), + mode, + }; +} + +export function resolveLiveFreshness(contentAt: string | null = null, now: Date = new Date()): Freshness { + return resolveFreshnessFrom(contentAt, now, "live"); } diff --git a/tests/developer-ingestion-page.dom.test.tsx b/tests/developer-ingestion-page.dom.test.tsx index daa0da8d97..01b56f8f43 100644 --- a/tests/developer-ingestion-page.dom.test.tsx +++ b/tests/developer-ingestion-page.dom.test.tsx @@ -53,12 +53,10 @@ describe("developer ingestion page — shell and freshness (plan §8)", () => { expect(screen.getByTestId("developer-ingestion-back")).toHaveAttribute("href", "/mockups/development"); // The shell's own stamp is rendered at server-render time, before the - // client panel has fetched anything — so it must say "revision unknown", + // client panel has fetched anything — so it says "read live on demand" (#XKS6FD), // not invent a build-time content date the way every Phase 1/2 panel does. - // Falsifying edit: passing `resolveRepoFreshness(snapshot, now)` (or any - // non-null contentAt) to PanelPageShell in page.tsx turns this red. const shellStamp = screen.getByTestId("developer-hub-freshness"); - expect(shellStamp).toHaveTextContent(/Ingestion jobs revision unknown/i); + expect(shellStamp).toHaveTextContent(/Ingestion jobs read live on demand/i); await screen.findByTestId("developer-ingestion-empty"); }); @@ -79,7 +77,7 @@ describe("developer ingestion page — shell and freshness (plan §8)", () => { // above already falsifies an omission; asserting real content here also // falsifies a component that renders the testid but leaves it empty. expect(checkedAt).toHaveTextContent(/checked/i); - expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/revision unknown/i); + expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/read live on demand/i); }); }); diff --git a/tests/developer-panel-page-shell.dom.test.tsx b/tests/developer-panel-page-shell.dom.test.tsx index 0ae83ddb8c..e9749f4970 100644 --- a/tests/developer-panel-page-shell.dom.test.tsx +++ b/tests/developer-panel-page-shell.dom.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { FreshnessStamp } from "@/components/developer-area/hub/freshness-stamp"; import { PanelPageShell } from "@/components/developer-area/hub/panel-page-shell"; -import { resolveFreshnessFrom } from "@/lib/developer-area/freshness"; +import { formatRelativeAge, resolveFreshnessFrom, resolveLiveFreshness } from "@/lib/developer-area/freshness"; // PanelPageShell's back control is a ContextualBackLink, which calls // next/navigation's useRouter for its history-aware click handler. Outside an @@ -34,6 +34,22 @@ describe("resolveFreshnessFrom", () => { // prevent. Guard here, not only in the formatter. expect(resolveFreshnessFrom("not-a-date", NOW).ageHours).toBeNull(); }); + + it("formats relative intervals cleanly without 0 hours ago (#FDST2Q)", () => { + expect(formatRelativeAge(10_000)).toBe("just now"); + expect(formatRelativeAge(59_000)).toBe("just now"); + expect(formatRelativeAge(120_000)).toBe("< 1 hour ago"); + expect(formatRelativeAge(3_500_000)).toBe("< 1 hour ago"); + expect(formatRelativeAge(3_600_000)).toBe("1 hour ago"); + expect(formatRelativeAge(7_200_000)).toBe("2 hours ago"); + }); + + it("creates live freshness with mode live (#XKS6FD)", () => { + const live = resolveLiveFreshness(null, NOW); + expect(live.mode).toBe("live"); + expect(live.contentAt).toBeNull(); + expect(live.viewedAt).toBe(NOW.toISOString()); + }); }); describe("FreshnessStamp label", () => { @@ -52,6 +68,11 @@ describe("FreshnessStamp label", () => { render(); expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/Repository revision unknown/); }); + + it("renders live status when mode is live (#XKS6FD)", () => { + render(); + expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/Ingestion jobs read live on demand/); + }); }); describe("PanelPageShell", () => { diff --git a/tests/developer-review-state-page.dom.test.tsx b/tests/developer-review-state-page.dom.test.tsx index c77d175e48..ecffaed37f 100644 --- a/tests/developer-review-state-page.dom.test.tsx +++ b/tests/developer-review-state-page.dom.test.tsx @@ -1,4 +1,5 @@ import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import DeveloperReviewStatePage from "@/app/mockups/development/review-state/page"; @@ -44,11 +45,18 @@ describe("developer review state page", () => { expect(screen.getByTestId("developer-review-state-scope")).toHaveTextContent(/pull request/i); }); - it("renders every record, dropping none", () => { + it("renders paginated records (up to 50 on page 1) and navigates to the next page", async () => { + const user = userEvent.setup(); render(); + const expectedFirstPageCount = Math.min(50, snapshot.review_state.counts.records); expect(within(screen.getByTestId("developer-review-state-records")).getAllByRole("listitem")).toHaveLength( - snapshot.review_state.counts.records, + expectedFirstPageCount, ); + expect(screen.getByText(/Showing 1–50 of/)).toBeInTheDocument(); + + const nextButtons = screen.getAllByRole("button", { name: "Next page" }); + await user.click(nextButtons[0]); + expect(screen.getByText(/Showing 51–100 of/)).toBeInTheDocument(); }); it("shows the newest record first and never a raw escaped pipe", () => { @@ -59,20 +67,14 @@ describe("developer review state page", () => { }); it("renders a record's outcome verbatim, in full, never classified or truncated (ruling R7)", () => { - // Nothing branches on `outcome` today, so this ruling holds by - // construction — but that also means a future edit adding, say, - // colour-coded badges or a `slice(0, 80)` truncation would pass every - // other assertion in this file. Picking the record with the longest - // outcome, rather than any record, is what makes a truncation regression - // fail here: a short outcome could stay a false substring match of itself - // even after truncation. render(); const rows = within(screen.getByTestId("developer-review-state-records")).getAllByRole("listitem"); + const pageRecords = snapshot.review_state.records.slice(0, 50); let longestIndex = 0; - for (const [index, record] of snapshot.review_state.records.entries()) { - if (record.outcome.length > snapshot.review_state.records[longestIndex].outcome.length) longestIndex = index; + for (const [index, record] of pageRecords.entries()) { + if (record.outcome.length > pageRecords[longestIndex].outcome.length) longestIndex = index; } - const longestRecord = snapshot.review_state.records[longestIndex]; + const longestRecord = pageRecords[longestIndex]; expect(longestRecord.outcome.length).toBeGreaterThan(40); expect(rows[longestIndex]).toHaveTextContent(longestRecord.outcome); }); diff --git a/tests/search-pins-menu.dom.test.tsx b/tests/search-pins-menu.dom.test.tsx index 0d79c828dd..ef76137e3a 100644 --- a/tests/search-pins-menu.dom.test.tsx +++ b/tests/search-pins-menu.dom.test.tsx @@ -195,14 +195,28 @@ describe("SearchPinsMenu", () => { expect(screen.getByRole("button", { name: /Choose another search area/ })).toBeInTheDocument(); }); - it("renders every supplied mode action", async () => { + it("renders every supplied mode action as a disclosure group item", async () => { const user = userEvent.setup(); const onAction = vi.fn(); renderMenu({ actions: longActionList, onAction }); - expect(screen.getByRole("menuitem", { name: "Search sources" })).toBeInTheDocument(); - expect(screen.getByRole("menuitem", { name: "Tables" })).toBeInTheDocument(); - await user.click(screen.getByRole("menuitem", { name: "Open source PDF" })); + expect(screen.getByRole("group", { name: "Useful actions" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Search sources" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Tables" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Open source PDF" })); expect(onAction).toHaveBeenCalledWith("documents-viewer"); }); + + it("closes the mode picker on Escape without dismissing the surrounding menu", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderMenu({ onClose }); + + await user.click(screen.getByRole("button", { name: /Choose another search area/ })); + expect(screen.getByRole("heading", { name: "Choose a search area" })).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + expect(screen.getByRole("heading", { name: "Your pins" })).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); }); diff --git a/tests/ward-capacity-view.dom.test.tsx b/tests/ward-capacity-view.dom.test.tsx index 4fe35e5049..402f0432c1 100644 --- a/tests/ward-capacity-view.dom.test.tsx +++ b/tests/ward-capacity-view.dom.test.tsx @@ -190,6 +190,17 @@ describe("ward capacity headline (Task 7)", () => { expect(screen.getByTestId("ward-capacity-headline-held")).toHaveTextContent("Held"); expect(screen.getByTestId("ward-capacity-headline-leave-usable")).toHaveTextContent("Leave (usable)"); + // Spec D9 (#WG24JB): confirmed and predicted pending discharge cards link to the discharge board. + expect(screen.getByTestId("ward-capacity-headline-confirmed-today")).toHaveAttribute( + "href", + "/mockups/ward-flow/discharges", + ); + expect(screen.getByTestId("ward-capacity-headline-predicted-today")).toHaveAttribute( + "href", + "/mockups/ward-flow/discharges", + ); + expect(screen.getByTestId("ward-capacity-headline-available-now")).not.toHaveAttribute("href"); + // No card anywhere in the headline claims to be a total/sum of the other four. expect(within(headline).queryByText(/total/i)).not.toBeInTheDocument(); expect(within(headline).queryByText(/^sum$/i)).not.toBeInTheDocument(); From 75cc4fd587c7c4347062415031a87d5d5f507cba Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:26:38 +0800 Subject: [PATCH 08/16] fix(design-system): use var(--duration-fast) in ward-management-modes.module.css without hardcoded fallback --- src/components/ward-management/ward-management-modes.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ward-management/ward-management-modes.module.css b/src/components/ward-management/ward-management-modes.module.css index d867708011..d4a112cb57 100644 --- a/src/components/ward-management/ward-management-modes.module.css +++ b/src/components/ward-management/ward-management-modes.module.css @@ -537,7 +537,7 @@ .summaryLinkCard { display: block; cursor: pointer; - transition: background-color var(--duration-fast, 150ms) ease; + transition: background-color var(--duration-fast) ease; } .summaryLinkCard:hover { From ae1e839f52e87774a5be8e9290d9991b147c36b7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:37:24 +0800 Subject: [PATCH 09/16] fix(developer-area): fix ReviewRecord import in ReviewStateTable --- src/components/developer-area/hub/review-state-table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/developer-area/hub/review-state-table.tsx b/src/components/developer-area/hub/review-state-table.tsx index b4a365a699..e355ab3932 100644 --- a/src/components/developer-area/hub/review-state-table.tsx +++ b/src/components/developer-area/hub/review-state-table.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { CARD_CLASS, META_CLASS, MONO_CLASS, ROW_CLASS } from "@/components/developer-area/hub/panel-primitives"; -import type { ReviewStateRecord } from "@/lib/developer-area/repo-awareness-types"; +import type { ReviewRecord } from "@/lib/developer-area/repo-awareness-types"; const DISCLOSURE_CLASS = "min-h-12 cursor-pointer text-xs font-bold text-[color:var(--text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -16,7 +16,7 @@ export function ReviewStateTable({ records, defaultPageSize = 50, }: { - records: readonly ReviewStateRecord[]; + records: readonly ReviewRecord[]; defaultPageSize?: number; }) { const [page, setPage] = useState(1); From d29f5429dbdba18bf56c883282fd3f26590231f2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:51:36 +0800 Subject: [PATCH 10/16] style: format docs/design-system/GATES.md --- docs/design-system/GATES.md | 58 ++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index 9292872679..7387ca9b74 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -146,35 +146,35 @@ theme-list parity, and remote design-project publication remain separate concern ## 3 · The prohibition table -| Never do this | Gate | Status | -| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reintroduce `.ckb-v2:not(.dark)` | Cascade computed-style test (PR 1) + contract-test selector filter update | implemented-blocking — landed #1538; contract parser + dark-ink regression guard | -| Add `prefers-color-scheme` rules to the v2 layer | — (users pin a theme against OS preference) | **manual** | -| Paint a numeral with a status colour; use amber/red decoratively | `check:design-system-contract` — `statusColouredNumerals` + `colourOnlyStatusIndicators` | **implemented-blocking** — both metrics and their per-path baselines are pinned at **0**; see §5 | -| Put `--decoration-soft`/`--text-soft` on a text node | Tier pinned both sides in the token contract; recipe AST (`decoration-on-text.contract.test.ts`) | implemented-blocking (recipes); broader AST follow-on | -| Uppercase a dose unit | `Quantity` pins `normal-case` + unit test | implemented-blocking (branch) | -| Invert a PDF, diagram or clinical image in any theme | `check:design-system-contract` — `imageInversions`, pinned at **zero**, not ratcheted | **implemented-blocking** (9 Aug 2026) — CSS `filter`/`backdrop-filter` plus the Tailwind `invert`/`hue-rotate` utilities; see §5 | -| Border **and** ring on one surface, or a 1px spread in a drop shadow | `check:design-system-contract` — `edgeOwnershipConflicts` (6) + `onePixelShadowSpreads` (0) | **implemented-blocking for new use** — AST/CSS ratchets with per-path pins; the recorded debt itself is Gate 8's remaining half | -| A child shadow heavier than its parent's | Gate 7 | implemented-partial | -| Use `--shadow-tight`/any alias in new code | `check:design-system-contract` — `legacyShadowAliases`, ratcheted at 34 with per-path pins | **implemented-blocking for new use** — a new alias in any file fails. `--shadow-tight` itself is retired onto `--e1` and additionally pinned by a tracked-tree sweep in `tests/design-token-contract.test.ts` (it was silently reverted once by the `acf78bf` merge); the remaining 34 are the other six aliases after the e2 (`--shadow-card`/`--shadow-soft`) family pay-down, still `#262` | -| Raw pixel size, padding, radius, gap or line-height in markup | `check:design-system-contract` — `rawPaddingLiterals` (52), `rawRadiusLiterals` (20), `rawGapLiterals` (25), `rawMarginLiterals` (74), `rawLineHeightLiterals` (0) | **implemented-blocking for new use** (9 Aug 2026) — per-path ratchets over both the utility and the CSS-declaration spelling, so a literal cannot move into `globals.css` to escape. Values containing a CSS function (`env(`, `clamp(`, `max(`, `calc(`) are sanctioned computed forms and exempt. Raw _size_ is still covered only for tap/shadow/colour | -| Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | `check:design-system-contract` — `layoutTransitionExceptions`, ratcheted at 11 with per-path pins | **implemented-blocking for new use** — `SAFE_TRANSITION_PROPERTIES` carries the compositor-only allowlist; phone chrome's deliberate `grid-template-rows` is in the recorded 11 | -| Hardcode a transition duration | `check:design-system-contract` — `hardcodedMotionClasses` (**zero**) + `hardcodedCssMotionDurations` (41) | **implemented-blocking** for the Tailwind `duration-*`/`delay-*`/`transition-all` form; the CSS form is a ratchet, so its 41 are debt | -| A `z-[N]` literal outside the named rungs, in JS/TS only | `require-z-index-ladder` (ESLint) | implemented-blocking. Scope: `z-[N]` literals in JS/TS AST nodes. Plain `z-30` utilities and every `.css` / `.module.css` declaration are invisible to an ESLint rule - `sidebar-live-shell.module.css` carries an off-ladder `z-index: var(--z-sidebar-flyout, 50)` this cannot see. | -| Ship a registered component without a direct publication test | Gate 12 | implemented-blocking | -| Lower a production tap target below 48px | `check:design-system-contract` — `interactiveTapFloorDeclarations` (9) + Gate 2's phone-viewport `ui-style-contract` audit + `ui-smoke` flake history | **implemented-blocking for new use** (15 Aug 2026) — a new interactive control declaring a sub-floor `min-h-*`, including comparable arbitrary values and any reachable conditional branch, fails on both the total and its own path; the 9 recorded sites remain debt. Component-wrapper tags such as `` are a known blind spot | -| Construct an enabled control with no action, destination or name | PR 4 discriminated unions + DOM contracts; `require-button-wiring` covers raw ` - - {currentPage} / {totalPages} - - -

    - )} + {totalPages > 1 && pager("Review records pagination")}
      - {currentRecords.map((record, index) => { + {records.map((record, index) => { const globalIndex = startIndex + index; return (
    1. - {totalPages > 1 && ( -
      -
      - - - {currentPage} / {totalPages} - - -
      -
      - )} + {totalPages > 1 &&
      {pager("Review records pagination bottom")}
      } ); } diff --git a/tests/developer-review-state-page.dom.test.tsx b/tests/developer-review-state-page.dom.test.tsx index ecffaed37f..ebbb9d8316 100644 --- a/tests/developer-review-state-page.dom.test.tsx +++ b/tests/developer-review-state-page.dom.test.tsx @@ -1,8 +1,8 @@ import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import DeveloperReviewStatePage from "@/app/mockups/development/review-state/page"; +import { ReviewStatePageContent } from "@/components/developer-area/hub/review-state-page-content"; import { loadRepoAwarenessSnapshot } from "@/lib/developer-area/repo-awareness-snapshot"; // PanelPageShell's back control is a ContextualBackLink, which calls @@ -14,6 +14,19 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), })); +// Pagination is now real ?page=N navigation (ReviewStateTable renders next/link, +// not client state) — mirrors tests/ward-capacity-view.dom.test.tsx: a plain +// avoids requiring an App Router context jsdom cannot provide, and lets these +// tests assert the href a click would follow instead of simulating the +// navigation itself. +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + vi.mock("@/lib/developer-area/repo-awareness-snapshot", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadRepoAwarenessSnapshot: vi.fn(actual.loadRepoAwarenessSnapshot) }; @@ -23,13 +36,13 @@ const snapshot = loadRepoAwarenessSnapshot(); describe("developer review state page", () => { it("renders inside the shared shell with the repository freshness label", () => { - render(); + render(); expect(screen.getByTestId("developer-review-state")).toBeInTheDocument(); expect(screen.getByTestId("developer-hub-freshness")).toHaveTextContent(/Repository/); }); it("shows records and distinct recorded refs as separate readable values", () => { - render(); + render(); expect(screen.getByTestId("developer-review-state-count-records-value")).toHaveTextContent( String(snapshot.review_state.counts.records), ); @@ -40,34 +53,51 @@ describe("developer review state page", () => { }); it("states what the page does not show, so a reader cannot infer live pull-request state", () => { - render(); + render(); expect(screen.getByTestId("developer-review-state-scope")).toHaveTextContent(/does not show/i); expect(screen.getByTestId("developer-review-state-scope")).toHaveTextContent(/pull request/i); }); - it("renders paginated records (up to 50 on page 1) and navigates to the next page", async () => { - const user = userEvent.setup(); - render(); + it("renders only the current page's records (up to 50 on page 1) — never the full committed set", () => { + render(); const expectedFirstPageCount = Math.min(50, snapshot.review_state.counts.records); expect(within(screen.getByTestId("developer-review-state-records")).getAllByRole("listitem")).toHaveLength( expectedFirstPageCount, ); expect(screen.getByText(/Showing 1–50 of/)).toBeInTheDocument(); + }); - const nextButtons = screen.getAllByRole("button", { name: "Next page" }); - await user.click(nextButtons[0]); + it("links Next to ?page=2 and renders page 2's distinct slice when navigated there", () => { + const { unmount } = render(); + const nextLinks = screen.getAllByRole("link", { name: "Next page" }); + expect(nextLinks[0]).toHaveAttribute("href", "?page=2"); + expect(screen.queryByRole("link", { name: "Previous page" })).not.toBeInTheDocument(); + unmount(); + + render(); expect(screen.getByText(/Showing 51–100 of/)).toBeInTheDocument(); + const previousLinks = screen.getAllByRole("link", { name: "Previous page" }); + expect(previousLinks[0]).toHaveAttribute("href", "?page=1"); + const secondPageRows = within(screen.getByTestId("developer-review-state-records")).getAllByRole("listitem"); + expect(secondPageRows[0]).toHaveTextContent(snapshot.review_state.records[50].head); + }); + + it("clamps an out-of-range requested page to the last real page", () => { + render(); + const totalPages = Math.max(1, Math.ceil(snapshot.review_state.counts.records / 50)); + expect(screen.getByText(new RegExp(`Page ${totalPages} of ${totalPages}`))).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Next page" })).not.toBeInTheDocument(); }); it("shows the newest record first and never a raw escaped pipe", () => { - render(); + render(); const rows = within(screen.getByTestId("developer-review-state-records")).getAllByRole("listitem"); expect(rows[0]).toHaveTextContent(snapshot.review_state.records[0].head); expect(screen.getByTestId("developer-review-state-records").textContent).not.toMatch(/\\\|/); }); it("renders a record's outcome verbatim, in full, never classified or truncated (ruling R7)", () => { - render(); + render(); const rows = within(screen.getByTestId("developer-review-state-records")).getAllByRole("listitem"); const pageRecords = snapshot.review_state.records.slice(0, 50); let longestIndex = 0; @@ -84,7 +114,7 @@ describe("developer review state page", () => { review_state: { ...snapshot.review_state, records: [], counts: { records: 0, refs: 0 } }, }); - render(); + render(); expect(screen.getByTestId("developer-review-state-empty")).toHaveTextContent( "No immutable review records are committed.", From 6d50be2e92fc83bb58a390b123110221e3bb1b37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 20:11:36 +0000 Subject: [PATCH 16/16] fix(test): disambiguate Search action locator after menuitem->button migration Production UI CI caught this: dailyActions.getByRole("button", { name: "Search" }) is a substring match, and once the daily-actions menu's items became plain buttons (search-pins-menu.tsx role=group migration) instead of menuitems, that substring matched four buttons ("Close pins and search options", "Search Answer Current search", "Choose another search area", "Search sources") instead of the one menuitem it used to find. Match the actual action label ("Search sources") with exact:true. --- tests/ui-smoke.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index ca95c2bac6..74d187e9b9 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1134,7 +1134,7 @@ test.describe("Clinical KB UI smoke coverage", () => { if (viewport.width < 640) { const dailyActionsTrigger = page.getByRole("button", { name: "Open answer options" }); const dailyActions = await openDailyActions(page); - const searchAction = dailyActions.getByRole("button", { name: "Search" }); + const searchAction = dailyActions.getByRole("button", { name: "Search sources", exact: true }); await expect(searchAction).toBeVisible(); await expect(dailyActions.getByRole("button", { name: "View evidence" })).toBeVisible(); await expectMinTouchTarget(searchAction);