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
10 changes: 8 additions & 2 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ contract and the code — not reinvention. If a change genuinely needs a new dir
- **Forced-colors and reduced-motion are first-class.** `globals.css` remaps all tokens under
`@media (forced-colors: active)` and zeroes motion under `prefers-reduced-motion: reduce`.
Never inline a style that defeats these. Every bespoke `transition`/`animate` needs
`motion-reduce:` handling or one of the pre-wired `--animate-*` tokens.
`motion-reduce:` handling or one of the pre-wired `--animate-*` tokens. Scripted
`scrollTo`/`scrollIntoView` must resolve `behavior` through `resolveScrollBehavior()`
(`src/lib/scroll-behavior.ts`) — a hard-coded `behavior: "smooth"` overrides the
reduced-motion CSS and ignores the preference.

### Legacy-hex migration table

Expand Down Expand Up @@ -104,7 +107,7 @@ Icon **glyphs** use the parallel `--spacing-icon-*` scale in `@theme`:
- Radii come from `@theme`: `rounded-md` chips/pills · `rounded-lg` controls/cards/panels ·
`rounded-xl`+ sheets/dialogs. Never pass a radius token through an arbitrary value
(`rounded-[var(--radius-md)]` → `rounded-md`) — the plain utility is the same token.
- Shadows/elevation: `--shadow-tight/soft/card/hover/elevated/lux/inset` and `--glow-primary/
- Shadows/elevation: `--shadow-tight/soft/card/hover/elevated/lux/inset/rail-active` and `--glow-primary/
soft`, all re-tuned per theme and removed under forced-colors. No literal `box-shadow` values
in components.

Expand All @@ -127,6 +130,9 @@ rung — never a new number.
safe-area padding, and dark-mode surfaces. Do not hand-roll `role="dialog"` overlays —
the applications-launcher DetailDialog migration is the template for converting one.
- Empty and loading states use `EmptyState` / `LoadingPanel`, not bespoke markup.
- **Icon-only buttons use `IconButton`** (`ui-primitives.tsx`): its `label` prop is required and
renders `aria-label` + an `aria-hidden` glyph + a 44px tap target, so an unlabeled icon button
cannot be written by accident. Pass a recipe (`toolbarButton`, …) via `className` for chrome.
- Composer-chrome caveat: the `answer-footer-search-*` / `desktop-home-search-*` classes are
intentionally **unlayered** and beat Tailwind utilities on the same element — check the class
body before adding a utility there (see "CSS cascade layering" in
Expand Down
28 changes: 28 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ Ordinary Vitest and Playwright runs remove OpenAI, Supabase, database, and E2E c

Set `FAST_CHECK_SEED` to reproduce a property-test run. Local and ordinary CI runs default to `424242`; scheduled CI may derive a bounded seed from the run ID.

## Component tests (jsdom)

Two Vitest projects run under one `npm run test` (see `vitest.config.mts`):

- **node** (`tests/**/*.test.ts`) — pure logic, route handlers, and SSR-string assertions.
- **jsdom** (`tests/**/*.dom.test.tsx`) — interactive component tests via `@testing-library/react`. The `.dom.test.tsx` suffix is required; a `.test.ts` file is collected by the node project and has no DOM.

Author component tests to assert **user-visible behaviour**, not markup snapshots:

- Query by role and accessible name (`getByRole("button", { name: … })`) so a missing or wrong `aria-label` fails the test; drive interactions with `@testing-library/user-event`.
- Cover the state matrix the change touches — loading / empty / error / disabled — plus keyboard operability and focus where relevant.
- The shared setup (`tests/setup/jsdom.setup.ts`) registers jest-dom matchers, auto-unmounts between tests, and polyfills `matchMedia` (override per test with the exported `installMatchMediaStub`) and `Element.scrollIntoView`.
- Mock hooks/modules with `vi.mock`; when the factory needs a spy, create it with `vi.hoisted` so it exists when the hoisted mock runs.

Reference examples: `tests/icon-button.dom.test.tsx` (accessible-name contract), `tests/sheet.dom.test.tsx` (stacked-overlay keyboard + scroll-lock), `tests/scroll-behavior.dom.test.tsx` (reduced-motion), `tests/registry-retry.dom.test.tsx` (`vi.hoisted` hook mock + error recovery).

## Playwright ownership

The repository runner exclusively builds and serves each Playwright production app. It selects a safe port, verifies `/api/local-project-id`, uses an isolated `.next-playwright/<run-id>` build directory, replaces provider configuration with inert loopback values, and removes its server and output on success, failure, or signal. Playwright configuration never starts a server. The production boot guard permits this demo profile only when the output is isolated, provider mode is offline, credentials are absent, and the Supabase URL is the inert `127.0.0.1:1` target.
Expand All @@ -36,3 +52,15 @@ Blocking tests run with zero retries. CI publishes list, JUnit, and JSON reports
## CI topology

PR CI keeps static checks separate from one required full unit run with coverage. UI scope uses one required production Chromium invocation for non-quarantined critical, regression, and dashboard/document visual-artifact journeys, plus one advisory invocation for quarantined and mockup journeys. Build, migration, security, and release behavior remain independently scoped and unchanged.

## Contribution checklist (UI changes)

Before opening a UI PR, confirm:

- **Reuse first.** Check `src/components/ui-primitives.tsx` (class recipes plus `IconButton`, `AsyncButton`, `InlineNotice`, `EmptyState`, `LoadingPanel`, `ToggleSwitch`) and `src/components/ui/sheet.tsx` (the only overlay primitive) before hand-rolling. Icon-only buttons use `IconButton` (its `label` is a required prop).
- **Tokens only.** No raw hex or Tailwind palette classes, no literal shadows, no `text-[Npx]` — see [`docs/design-system.md`](./design-system.md) §1–§5. `check:design-system-contract`, `check:type-scale`, and `check:icon-scale` enforce this.
- **States.** Handle loading / empty / error / disabled where they apply; async surfaces expose a retry, not a dead end.
- **Accessibility** ([design-system §7](./design-system.md)): keyboard operable, visible focus, accessible names on icon controls, live regions for async status, and reduced motion honoured — scripted `scrollTo`/`scrollIntoView` go through `resolveScrollBehavior` (`src/lib/scroll-behavior.ts`), never a hard-coded `behavior: "smooth"`.
- **Tests.** Add a `.dom.test.tsx` for changed component behaviour (see "Component tests" above) and update the E2E journeys for changed flows.
- **Verify** ([design-system §9](./design-system.md)): run `npm run verify:cheap`, then `npm run verify:pr-local` before handoff; run `npm run ensure` before browser work and `npm run verify:ui` for UI/routing/styling changes, plus a manual dark-mode + forced-colors spot check on touched surfaces.
- Architecture and state-ownership conventions: [`docs/frontend-architecture.md`](./frontend-architecture.md).
4 changes: 2 additions & 2 deletions scripts/check-maintainability-budgets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import { readFileSync } from "node:fs";

const budgets = new Map([
["src/components/ClinicalDashboard.tsx", 4157],
["src/components/ClinicalDashboard.tsx", 4160],
["src/lib/rag/rag.ts", 5030],
["src/components/DocumentViewer.tsx", 1733],
["src/components/DocumentViewer.tsx", 1734],
["supabase/functions/indexing-v3-agent/index.ts", 2191],
]);

Expand Down
7 changes: 5 additions & 2 deletions scripts/check-type-scale.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
//
// Usage:
// node scripts/check-type-scale.mjs report only, exit 0 (default)
// node scripts/check-type-scale.mjs --strict exit 1 if any found (promote to a
// CI gate once the backlog is cleared)
// node scripts/check-type-scale.mjs --strict exit 1 if any found. Already wired
// into `verify:cheap` with the backlog
// cleared to 0, so this is a hard zero
// gate (no baseline) — unlike the
// ratcheting design-system contract.

import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
Expand Down
2 changes: 1 addition & 1 deletion scripts/design-system-contract-baseline.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"rawColorLiterals": 9,
"literalShadowClasses": 6,
"literalShadowClasses": 1,
"legacyTapClasses": 28
}
5 changes: 4 additions & 1 deletion src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
<body
style={{
margin: 0,
minHeight: "100vh",
// dvh (not vh) matches the app-wide convention so the centred card
// stays within the visible viewport on mobile browsers with dynamic
// toolbars, rather than being pushed partly below the fold.
minHeight: "100dvh",
display: "flex",
alignItems: "center",
justifyContent: "center",
Expand Down
4 changes: 4 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@
--shadow-lux: inset 0 1px 0 rgb(255 255 255 / 64%), 0 12px 34px rgb(8 16 24 / 7%);
--shadow-inset: inset 0 1px 0 rgb(255 255 255 / 58%);
--shadow-card: var(--shadow-tight);
/* Active left-rail marker (nav/list "current" indicator). One token so the
accent rail is a consistent 2px everywhere instead of hand-inlined at 2px/3px. */
--shadow-rail-active: inset 2px 0 0 var(--clinical-accent);
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
Expand Down Expand Up @@ -2458,6 +2461,7 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou
--shadow-elevated: none;
--shadow-lux: none;
--shadow-inset: none;
--shadow-rail-active: none;
}

button,
Expand Down
29 changes: 16 additions & 13 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "react";
import { type DocumentDeleteResult } from "@/components/DocumentManagementActions";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import { resolveScrollBehavior } from "@/lib/scroll-behavior";
import { isLocalNoAuthMode, resolveClientDemoMode, resolveUploadReadOnlyMode } from "@/lib/client-env";
import { isAdministratorUser } from "@/lib/authorization";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
Expand Down Expand Up @@ -842,7 +843,7 @@ export function ClinicalDashboard({
}

window.setTimeout(() => {
document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById(targetId)?.scrollIntoView({ behavior: resolveScrollBehavior(), block: "start" });
}, 0);
},
[canUseAdministrativeApis, closeDashboardTransientSurfaces],
Expand Down Expand Up @@ -1980,7 +1981,7 @@ export function ClinicalDashboard({
setLoading(false);
setError(null);
rememberRecentQuery(trimmedQuery);
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }));
return;
}
if (!canRunSearch) {
Expand Down Expand Up @@ -2196,7 +2197,7 @@ export function ClinicalDashboard({
if (isAnswerFollowUp) {
window.requestAnimationFrame(() => {
const main = mainRef.current;
main?.scrollTo({ top: main.scrollHeight, behavior: "smooth" });
main?.scrollTo({ top: main.scrollHeight, behavior: resolveScrollBehavior() });
});
}
}
Expand Down Expand Up @@ -2231,7 +2232,7 @@ export function ClinicalDashboard({
setError(null);
setAnswerProgress(null);
rememberRecentQuery(trimmedSearchText);
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }));
if (updateUrl) {
router.replace(appModeHomeHref("prescribing", { query: trimmedSearchText, queryMode, scopeFilters }));
}
Expand Down Expand Up @@ -2457,7 +2458,7 @@ export function ClinicalDashboard({
function answerFromDocument(documentId: string) {
setSelectedDocumentIds([documentId]);
setSearchMode("answer");
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }));
}

function updateDocumentSearchUrl(
Expand Down Expand Up @@ -2488,7 +2489,7 @@ export function ClinicalDashboard({
setError(null);
setAnswerProgress(null);
rememberRecentQuery(trimmedSearchText);
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }));
if (updateUrl) {
router.push(
documentsSearchHref({
Expand Down Expand Up @@ -2523,7 +2524,7 @@ export function ClinicalDashboard({
setSourceGovernanceWarnings([]);
setAnswerViewMode("high_yield");
rememberRecentQuery(trimmedSearchText);
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" }));
window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }));
if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode, filtersOverride);

const requestId = ++searchRequestSeqRef.current;
Expand Down Expand Up @@ -2710,7 +2711,7 @@ export function ClinicalDashboard({
setAnswerViewMode("high_yield");
router.replace(href);
window.requestAnimationFrame(() => {
mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() });
});
focusComposerInput();
}
Expand All @@ -2723,7 +2724,9 @@ export function ClinicalDashboard({
setDocumentsDrawerOpen(true);
if (window.matchMedia("(min-width: 1024px)").matches) {
window.requestAnimationFrame(() => {
document.getElementById("dashboard-documents-drawer")?.scrollIntoView({ block: "start", behavior: "smooth" });
document
.getElementById("dashboard-documents-drawer")
?.scrollIntoView({ block: "start", behavior: resolveScrollBehavior() });
});
}
}
Expand Down Expand Up @@ -2755,7 +2758,7 @@ export function ClinicalDashboard({
setUploadDrawerOpen(true);
window.requestAnimationFrame(() => {
const drawer = document.getElementById("dashboard-upload-drawer") as HTMLDetailsElement | null;
drawer?.scrollIntoView({ block: "start", behavior: "smooth" });
drawer?.scrollIntoView({ block: "start", behavior: resolveScrollBehavior() });
if (drawer && !drawer.open) {
drawer.querySelector<HTMLElement>("summary")?.click();
}
Expand All @@ -2766,7 +2769,7 @@ export function ClinicalDashboard({
closeDashboardTransientSurfaces();
const reviewTrigger = document.getElementById("answer-evidence-drawer-mobile-trigger") as HTMLButtonElement | null;
if (reviewTrigger) {
reviewTrigger.scrollIntoView({ block: "center", behavior: "smooth" });
reviewTrigger.scrollIntoView({ block: "center", behavior: resolveScrollBehavior() });
reviewTrigger.click();
return;
}
Expand Down Expand Up @@ -3461,12 +3464,12 @@ export function ClinicalDashboard({
// dock inside its scrollable content. Padding can collapse when the
// dock hides without exposing the app-shell background; the
// bottom-clamp guard in use-hide-on-scroll prevents false reveals.
"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-24"
"max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-24"
: hasMobileBottomSearch
? // Phones dock the compact composer on every non-answer view
// (mode homes included), so they always reserve dock
// clearance; sm+ keeps the in-flow hero/sticky composers.
"max-sm:pb-[var(--mobile-composer-reserve)] sm:mb-0"
"max-sm:pb-[var(--mobile-composer-reserve)] max-sm:[scroll-padding-bottom:var(--mobile-composer-reserve)] sm:mb-0"
: "mb-0",
)}
>
Expand Down
5 changes: 3 additions & 2 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge";
import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview";
import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache";
import { resolveScrollBehavior } from "@/lib/scroll-behavior";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
import {
documentLoadKey,
Expand Down Expand Up @@ -910,7 +911,7 @@ export function DocumentViewer({
return;
setSummary(payload);
window.requestAnimationFrame(() => {
generatedSummaryRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
generatedSummaryRef.current?.scrollIntoView({ block: "start", behavior: resolveScrollBehavior() });
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
Expand Down Expand Up @@ -1003,7 +1004,7 @@ export function DocumentViewer({
if (!activeChunkId || loadingDocument) return;
window.document
.querySelector<HTMLElement>(`[data-source-chunk-id="${CSS.escape(activeChunkId)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
?.scrollIntoView({ block: "center", behavior: resolveScrollBehavior() });
}, [activeChunkId, loadingDocument, chunks.length]);
const retryPreview = () => {
setViewerError(null);
Expand Down
7 changes: 7 additions & 0 deletions src/components/clinical-dashboard/account-setup-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export function AccountSetupDialog({
const [emailAttempted, setEmailAttempted] = useState(false);
const busy = auth.status === "loading";
const statusMessage = providerNotice ?? (emailAttempted ? auth.error : null);
// Only a submit error (not the SSO provider notice) marks the email field
// invalid and is associated to it, so assistive tech ties the alert to the
// control the user must correct.
const emailHasError = !providerNotice && Boolean(statusMessage);
const isFavouritesIntent = intent === "favourites";
const benefits = isFavouritesIntent ? favouritesAccountBenefits : accountBenefits;
const title = isFavouritesIntent ? "Sign up to save favourites" : "Set up your workspace";
Expand Down Expand Up @@ -167,6 +171,8 @@ export function AccountSetupDialog({
autoCorrect="off"
spellCheck={false}
required
aria-invalid={emailHasError || undefined}
aria-describedby={emailHasError ? "account-setup-status" : undefined}
className={cn(
fieldControlWithIcon,
"h-10.5 focus:ring-2 focus:ring-[color:var(--focus)]/20 sm:h-tap",
Expand Down Expand Up @@ -265,6 +271,7 @@ export function AccountSetupDialog({

{statusMessage ? (
<p
id="account-setup-status"
role={providerNotice ? "status" : "alert"}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-inset)] px-3 py-2 text-xs font-medium leading-5 text-[color:var(--text-muted)]"
>
Expand Down
7 changes: 6 additions & 1 deletion src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,12 @@ export function AnswerEmptyState({
}

export function AnswerSkeleton() {
// role=status (matching LoadingPanel) so the initial answer-pending window —
// after submit but before the first progress event — is announced. Without it
// the aria-label sits on a plain div and screen readers stay silent until the
// progress stepper (its own role=status) mounts.
return (
<div className="space-y-4" aria-label={answerLoading.ariaLabel}>
<div className="space-y-4" role="status" aria-label={answerLoading.ariaLabel}>
<div className="space-y-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-4">
<div className="h-4 w-10/12 animate-skeleton-shimmer rounded bg-[color:var(--surface-inset)]" />
<div className="h-4 w-full animate-skeleton-shimmer rounded bg-[color:var(--surface-inset)]" />
Expand All @@ -137,6 +141,7 @@ export function AnswerSkeleton() {
<div className="h-28 animate-skeleton-shimmer rounded-lg bg-[color:var(--surface-inset)]" />
<div className="hidden h-28 animate-skeleton-shimmer rounded-lg bg-[color:var(--surface-inset)] sm:block" />
</div>
<span className="sr-only">{answerLoading.ariaLabel}</span>
</div>
);
}
Expand Down
Loading
Loading